superdesk/superdesk-client-core

View on GitHub
scripts/core/editor3/helpers/find-replace.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
import {Modifier, EditorState, EditorChangeType, SelectionState, ContentState} from 'draft-js';
import {getData, setDataForContent, getCell, setCell} from './table';
import {escapeRegExp} from 'core/utils';
import {CustomEditor3Entity} from '../constants';

/**
 * @name clearHighlights
 * @param {ContentState} c The content to clear the highlights in.
 * @param {EditorState=} es If provided, the new content state is pushed into this editor state.
 * @returns {Object} Returns an object that contains two keys, the cleared content state and
 * the new editor state (if it was provided).
 * @description Clears all the highlights in the given content. If an editor state is provided,
 * it also returns it updated.
 */
export const clearHighlights = (c, es = null) => {
    let content = c;
    let editorState = es;
    let changedContent = false;

    const filterFn = (d) => d.hasStyle('HIGHLIGHT') || d.hasStyle('HIGHLIGHT_STRONG');

    content = forEachBlock(content, (blockIndex, block, _content) => {
        let newContent = _content;

        block.findStyleRanges(filterFn,
            (start, end) => {
                const selection = createSelection(block.getKey(), start, end);

                newContent = Modifier.removeInlineStyle(newContent, selection, 'HIGHLIGHT');
                newContent = Modifier.removeInlineStyle(newContent, selection, 'HIGHLIGHT_STRONG');

                changedContent = true;
            },
        );

        return newContent;
    });

    if (changedContent && editorState) {
        editorState = quietPush(editorState, content);
    }

    return {content, editorState};
};

/**
 * @name forEachBlock
 * @param {ContentState} content The content to interate over.
 * @param {function} cb The callback to call for each block. Receives index, block and content.
 * @returns {ContentState} returns  the new content state.
 * @description Iterates over blocks in  conntent and calls the given callback
 * for each block, passing it current index, block and content.
 */
export const forEachBlock = (content, cb) => {
    let newContent = content;

    content.getBlocksAsArray().forEach((block) => {
        const entityKey = block.getEntityAt(0);
        const entity = entityKey != null ? content.getEntity(entityKey) : null;

        let blockIndex = -1;

        if (entity != null && entity.getType() === CustomEditor3Entity.TABLE) {
            ({newContent, blockIndex} = forEachBlockInTable(newContent, block, blockIndex, cb));
        } else {
            newContent = cb(++blockIndex, block, newContent);
        }
    });

    return newContent;
};

/**
 * @name forEachBlockInTable
 * @param {ContentState} content The content to iterate  over.
 * @param {String} the block that contains the table.
 * @param {number} current marching index
 * @param {function} cb The callback to call for each block. Receives index, block and content.
 * @returns {ContentState} returns  the new content state.
 * @description Iterates the table content and calls the given callback
 * for each block, passing it current index, block and content.
 */
const forEachBlockInTable = (content, block, _blockIndex, cb) => {
    const key = block.getKey();
    const selection = createSelection(key, 0, 1);
    const data = getData(content, key);

    let newContent = content;
    let blockIndex = _blockIndex;

    for (let i = 0; i < (data.numRows || 0); i++) {
        for (let j = 0; j < (data.numCols || 0); j++) {
            let cellEditorState = getCell(data, i, j, null, null);
            let cellContent = cellEditorState.getCurrentContent();

            cellContent.getBlocksAsArray().forEach((_block) => {
                cellContent = cb(++blockIndex, _block, cellContent);
            });

            cellEditorState = EditorState.push(cellEditorState, cellContent, 'insert-characters');
            setCell(data, i, j, cellEditorState);
        }
    }

    newContent = setDataForContent(newContent, selection, block, data);

    return {newContent, blockIndex};
};

/**
 * @name forEachMatch
 * @param {ContentState} content The content to search in.
 * @param {string} pattern The pattern to search by.
 * @param {boolean} caseSensitive Whether the search should be case sensitive or not.
 * @param {function} cb The callback to call for each occurrence. Receives index, selection, block and content.
 * @returns {ContentState} returns  the new content state.
 * @description Searches the content for the given pattern and calls the given callback
 * for each occurrence, passing it current content, index of the match and its SelectionState.
 */
export const forEachMatch = (content, pattern, caseSensitive, cb) => {
    if (!pattern) {
        return false;
    }

    let matchIndex = -1;
    let newContent = content;

    content.getBlocksAsArray().forEach((block) => {
        const entityKey = block.getEntityAt(0);
        const entity = entityKey != null ? content.getEntity(entityKey) : null;

        if (entity != null && entity.getType() === 'TABLE') {
            ({newContent, matchIndex} = forEachMatchInTable(
                newContent, block, pattern, caseSensitive, matchIndex, cb));
        } else {
            ({newContent, matchIndex} = forEachMatchInParagraph(
                newContent, block, pattern, caseSensitive, matchIndex, cb));
        }
    });

    return newContent;
};

/**
 * @name forEachMatchInParagraph
 * @param {ContentState} content The content to search in.
 * @param {Block} the block to search in.
 * @param {string} pattern The pattern to search by.
 * @param {boolean} caseSensitive Whether the search should be case sensitive or not.
 * @param {number} current marching index
 * @param {function} cb The callback to call for each occurrence. Receives index, selection, block and content.
 * @returns {ContentState} returns  the new content state.
 * @description Searches the block for the given pattern and calls the given callback
 * for each occurrence, passing it the index of the match and its SelectionState.
 */
const forEachMatchInParagraph = (content, block, pattern, caseSensitive, _matchIndex, cb) => {
    const re = getRegExp({pattern, caseSensitive});
    const key = block.getKey();
    const text = block.getText();

    let newContent = content;
    let matchIndex = _matchIndex;
    let match;

    // eslint-disable-next-line no-cond-assign
    while (match = re.exec(text)) {
        newContent = cb(
            ++matchIndex,
            createSelection(key, match.index, match.index + match[0].length),
            block,
            newContent,
            match[0],
        );
    }

    return {newContent, matchIndex};
};

/**
 * @name forEachMatchInTable
 * @param {ContentState} content The parent content to search in.
 * @param {Block} the block that contains the table.
 * @param {string} pattern The pattern to search by.
 * @param {boolean} caseSensitive Whether the search should be case sensitive or not.
 * @param {number} current marching index
 * @param {function} cb The callback to call for each occurrence. Receives index, selection, block and content.
 * @returns {ContentState} returns  the new content state.
 * @description Searches the table content for the given pattern and calls the given callback
 * for each occurrence, passing it the index of the match and its SelectionState.
 */
const forEachMatchInTable = (content, block, pattern, caseSensitive, _matchIndex, cb) => {
    const key = block.getKey();
    const selection = createSelection(key, 0, 1);
    const data = getData(content, key);

    let newContent = content;
    let matchIndex = _matchIndex;

    for (let i = 0; i < (data.numRows || 0); i++) {
        for (let j = 0; j < (data.numCols || 0); j++) {
            let cellEditorState = getCell(data, i, j, null, null);
            let cellContent = cellEditorState.getCurrentContent();

            cellContent.getBlocksAsArray().forEach((_block) => {
                ({newContent: cellContent, matchIndex} = forEachMatchInParagraph(
                    cellContent, _block, pattern, caseSensitive, matchIndex, cb));
            });

            cellEditorState = EditorState.push(cellEditorState, cellContent, 'insert-characters');
            setCell(data, i, j, cellEditorState);
        }
    }

    newContent = setDataForContent(newContent, selection, block, data);

    return {newContent, matchIndex};
};

// create reg exp from pattern if needed
const getRegExp = ({pattern, caseSensitive}) =>
    typeof pattern === 'string' ? new RegExp(escapeRegExp(pattern), 'g' + (caseSensitive ? '' : 'i')) : pattern;

/**
 * @name quietPush
 * @param {EditorState} editorState
 * @param {ContentState} content
 * @description Silently pushes the new content state into the given editor state, without
 * affecting the undo/redo stack.
 */
export const quietPush = (editorState, content, changeType: EditorChangeType = 'insert-characters') => {
    let newState;

    newState = EditorState.set(editorState, {allowUndo: false});
    newState = EditorState.push(newState, content, changeType);
    newState = EditorState.set(newState, {allowUndo: true});

    return newState;
};

/**
 * Creates a new selection state, based on the given block key, having the specified
 * anchor and offset.
 */
const createSelection = (key: string, start: number, end: number): SelectionState =>
    SelectionState.createEmpty(key).merge({
        anchorOffset: start,
        focusOffset: end,
    }) as SelectionState;

export function replaceAllForEachBlock(
    contentState: ContentState,
    regex: RegExp, // a global regex must be passed
    replaceWith: string,
): ContentState {
    let result: ContentState = contentState;

    for (const block of contentState.getBlocksAsArray()) {
        const blockKey = block.getKey();

        const matches: Array<{index: number; text: string}> =
            Array.from(block.getText().matchAll(regex))
                .map((match) => ({index: match.index, text: match[0]}));

        let offsetCorrection = 0;

        const correctOffset = (n) => n + offsetCorrection;

        for (const match of matches) {
            const anchorOffset = correctOffset(match.index);
            const focusOffset = correctOffset(match.index + match.text.length);
            const rangeToReplace = new SelectionState({
                anchorKey: blockKey,
                anchorOffset,
                focusKey: blockKey,
                focusOffset,
            });

            const firstCharStyle = block.getInlineStyleAt(anchorOffset);

            let consistentStyle = true;

            for (let i = anchorOffset + 1; i <= focusOffset; i++) {
                const charStyles = block.getInlineStyleAt(i);

                // eslint-disable-next-line max-depth
                if (charStyles.equals(firstCharStyle) !== true) {
                    consistentStyle = false;
                    break;
                }
            }

            result = Modifier.replaceText(
                result,
                rangeToReplace,
                replaceWith,
                consistentStyle ? firstCharStyle : undefined,
            );

            offsetCorrection += replaceWith.length - match.text.length;
        }
    }

    return result;
}