superdesk/superdesk-client-core

View on GitHub
scripts/core/editor3/reducers/toolbar.tsx

Summary

Maintainability
C
1 day
Test Coverage
import {RichUtils, EditorState, ContentState, SelectionState, convertToRaw, EntityInstance} from 'draft-js';
import * as entityUtils from '../components/links/entityUtils';
import {onChange} from './editor3';
import * as Links from '../helpers/links';
import * as Blocks from '../helpers/blocks';
import * as Highlights from '../helpers/highlights';
import {removeFormatFromState, removeAllFormatAndStyles} from '../helpers/removeFormat';
import {insertEntity} from '../helpers/draftInsertEntity';
import {IEditorStore} from '../store';
import {assertNever} from 'core/helpers/typescript-helpers';
import {ITextCase} from '../actions';
import {PopupTypes} from '../actions/popups';
import {getCell, getData, IEditor3TableData, setData} from '../helpers/table';
import {processCells} from './table';
import {ILink} from '../components/links/LinkInput';
import {CustomEditor3Entity} from '../constants';

/**
 * @description Contains the list of toolbar related reducers.
 */
const toolbar = (state: IEditorStore, action) => {
    switch (action.type) {
    case 'TOOLBAR_TOGGLE_BLOCK_STYLE':
        return toggleBlockStyle(state, action.payload);
    case 'TOOLBAR_TOGGLE_INLINE_STYLE':
        return toggleInlineStyle(state, action.payload);
    case 'TOOLBAR_APPLY_LINK':
        return applyLink(state, action.payload);
    case 'TOOLBAR_APPLY_LINK_MULTI-LINE_QUOTE':
        return applyLinkToMultiLineQuote(state, action.payload);
    case 'TOOLBAR_REMOVE_LINK':
        return removeLink(state);
    case 'TOOLBAR_REMOVE_LINK_MULTI-LINE_QUOTE':
        return removeLinkInMultiLineQuote(state);
    case 'TOOLBAR_REMOVE_FORMAT':
        return removeFormat(state);
    case 'TOOLBAR_REMOVE_ALL_FORMAT':
        return removeAllFormat(state);
    case 'TOOLBAR_INSERT_MEDIA':
        return insertMedia(state, action.payload);
    case 'TOOLBAR_UPDATE_IMAGE':
        return updateImage(state, action.payload);
    case 'TOOLBAR_REMOVE_BLOCK':
        return removeBlock(state, action.payload);
    case 'TOOLBAR_SET_POPUP':
        return setPopup(state, action.payload);
    case 'TOOLBAR_TOGGLE_INVISIBLES':
        return toggleInvisibles(state);
    case 'CHANGE_CASE':
        return changeCase(state, action.payload);
    case 'UNDO':
        return onChange(state, EditorState.undo(state.editorState));
    case 'REDO':
        return onChange(state, EditorState.redo(state.editorState));
    case 'SET_CUSTOM_TOOLBAR' :
        return setCustomToolbar(state, action.payload);
    case 'SET_MULTI-LINE_QUOTE_POPUP' :
        return setMultiLinePopup(state, action.payload);
    default:
        return state;
    }
};

/**
 * @ngdoc method
 * @name toggleBlockStyle
 * @param {string} blockStyle
 * @description Applies the given block style.
 */
const toggleBlockStyle = (state, blockType) => {
    const {editorState} = state;
    const stateAfterChange = RichUtils.toggleBlockType(
        editorState,
        blockType,
    );

    return onChange(state, stateAfterChange);
};

/**
 * @ngdoc method
 * @name toggleInlineStyle
 * @param {string} inlineStyle
 * @description Applies the given inline style.
 */
const toggleInlineStyle = (state, inlineStyle) => {
    const {editorState} = state;

    let stateAfterChange = RichUtils.toggleInlineStyle(
        editorState,
        inlineStyle,
    );

    // Check if there was a suggestions to toggle that style
    stateAfterChange = handleExistingInlineStyleSuggestionOnToggle(stateAfterChange, inlineStyle);

    return onChange(state, stateAfterChange);
};

const handleExistingInlineStyleSuggestionOnToggle = (editorState, inlineStyle) => {
    const type = Highlights.getTypeByInlineStyle(inlineStyle);
    const highlightName = Highlights.getHighlightStyleAtCurrentPosition(editorState, type);

    if (highlightName) {
        return Highlights.removeHighlight(editorState, highlightName);
    }

    return editorState;
};

/**
 * @ngdoc method
 * @name applyLink
 * @param {Object} link Link data to apply
 * @param {Entity|null} entity The entity to apply the URL too.
 * @description Applies the given URL to the current content selection. If an
 * entity is specified, it applies the link to that entity instead.
 */
const applyLink = (state, {link, entity}) => {
    let {editorState} = state;

    if (entity) {
        return onChange(state, entityUtils.replaceSelectedEntityData(editorState, {link}), true);
    }

    editorState = Links.createLink(editorState, link);
    return onChange(state, editorState);
};

function applyChangesToTableCell(
    state: IEditorStore,
    operation: (editorState: EditorState) => EditorState,
): IEditorStore {
    const {activeCell, editorState: mainEditorState} = state;

    if (activeCell === null) {
        return state;
    }

    const {i, j, key, currentStyle, selection} = activeCell;
    const contentState = mainEditorState.getCurrentContent();
    const block = contentState.getBlockForKey(key);
    const data = getData(contentState, block.getKey());
    const cellEditorState = getCell(data, i, j, currentStyle, selection);

    const editorStateNext = operation(cellEditorState);

    const dataNew: IEditor3TableData = {
        ...data,
        cells: [[convertToRaw(editorStateNext.getCurrentContent())]],
    };

    const newMainState = setData(mainEditorState, block, dataNew, 'change-block-data');

    return onChange(state, newMainState, true);
}

/**
 * Applies the given URL to the current content selection in multi-line quote block.
 * If the selection is a link, it applies the link to the entity instead.
 */
const applyLinkToMultiLineQuote = (state, {link, entity}: {link: ILink, entity: EntityInstance}) =>
    applyChangesToTableCell(state, (editorState) =>
        entity
            ? entityUtils.replaceSelectedEntityData(editorState, {link})
            : Links.createLink(editorState, link),
    );

/**
 * Removes the link on the entire entity under the cursor in multi-line quote block.
 */
const removeLinkInMultiLineQuote = (state) => {
    const {activeCell, editorState: mainEditorState} = state;

    if (activeCell === null) {
        return state;
    }

    const {i, j, key, currentStyle, selection} = activeCell;
    const contentState = mainEditorState.getCurrentContent();
    const block = contentState.getBlockForKey(key);
    const data = getData(contentState, block.getKey());
    const cellEditorStateWithRemovedLink =
        Links.removeLink(getCell(data, i, j, currentStyle, selection));

    const newData: IEditor3TableData = {
        ...data.data,
        cells: [[convertToRaw(cellEditorStateWithRemovedLink.getCurrentContent())]],
    };
    const editorState = setData(mainEditorState, block, newData, 'change-block-data');

    return onChange(state, editorState);
};

/**
 * @ngdoc method
 * @name removeLink
 * @description Removes the link on the entire entity under the cursor.
 */
const removeLink = (state) => {
    let {editorState} = state;

    editorState = Links.removeLink(editorState);
    return onChange(state, editorState);
};

/*
 * Converts the state in plain text
 */
const removeAllFormat = (state) => {
    return onChange(state, removeAllFormatAndStyles(state.editorState));
};

/*
 * @ngdoc method
 * @name removeFormat
 * @description Set all blocks in selection to unstyled except atomic blocks
 *              and remove inline styles
 */
const removeFormat = (state) => {
    const {editorState} = state;
    const selection = editorState.getSelection();
    const stateWithoutFormat = removeFormatFromState(editorState);
    const newSelection = selection.merge({
        anchorOffset: selection.getEndOffset(),
        anchorKey: selection.getEndKey(),
        focusOffset: selection.getEndOffset(),
        focusKey: selection.getEndKey(),
        isBackward: false,
        hasFocus: true,
    });

    return onChange(state, EditorState.acceptSelection(stateWithoutFormat, newSelection));
};

/**
 * @ngdoc method
 * @name insertMedia
 * @param {Array} files List of media files to be inserted into document.
 * @param {String} targetBlockKey Block key where we want to insert the media
 * @description Inserts a list of media files into the document.
 */
const insertMedia = (state, {files = [], targetBlockKey = null}) => {
    let {editorState} = state;

    files.forEach((file) => {
        editorState = addMedia(editorState, file, targetBlockKey);
    });

    return onChange(state, editorState);
};

/**
 * @ngdoc method
 * @name addMedia
 * @param {Object} editorState Editor state to add the media to.
 * @param {Object} media Media data.
 * @param {String} targetBlockKey Block key where the media is going to
 * @returns {Object} New editor state with media inserted as atomic block.
 * @description Inserts the given media into the given editor state's content and returns
 * the updated editor state.
 */
export const addMedia = (editorState: EditorState, media, targetBlockKey = null): EditorState =>
    insertEntity(editorState, CustomEditor3Entity.MEDIA, 'MUTABLE', {media}, targetBlockKey);

export const addArticleEmbed = (editorState: EditorState, data, targetBlockKey = null): EditorState =>
    insertEntity(editorState, CustomEditor3Entity.ARTICLE_EMBED, 'MUTABLE', data, targetBlockKey);

/**
 * @ngdoc method
 * @name updateImage
 * @param {Object} data Contains the entityKey and the new image data.
 * @description Updates the given entityKey with the new image data.
 */
const updateImage = (state, {entityKey, media}) => {
    const {editorState} = state;
    const selection = editorState.getSelection();
    const contentState = editorState.getCurrentContent();
    const newContentState = contentState.replaceEntityData(entityKey, {media});
    const newEditorState = EditorState.push(editorState, newContentState, 'change-block-data');
    // focus the editor and softly force a refresh
    const newState = EditorState.forceSelection(newEditorState, selection);
    const entityDataHasChanged = true;

    return onChange(state, newState, entityDataHasChanged);
};

/**
 * @ngdoc method
 * @name removeBlock
 * @param {Object} data Contains the key from of block to remove
 * @description Removes block from editor
 */
const removeBlock = (state, {blockKey}) => {
    const {editorState} = state;
    const newEditorState = Blocks.removeBlock(editorState, blockKey);

    return onChange(state, newEditorState);
};

/**
 * @ngdoc method
 * @name toggleInvisibles
 * @param {Object} state
 * @return {Object} returns new state
 * @description Enable/Disable the paragraph marks
 */
const toggleInvisibles = (state) => {
    const {invisibles} = state;

    return {
        ...state,
        invisibles: !invisibles,
    };
};

/**
 * @ngdoc method
 * @name setPopup
 * @param {Object} data Type of popup and popup data.
 * @description Sets the toolbar popup to the given type.
 */
const setPopup = (state: IEditorStore, {type, data}) => {
    const {editorState} = state;
    let newEditorState = editorState;

    // SDESK-3885
    // Whenever we hide a popup, the ContentState is not changed so it will
    // trigger these two DraftJS events:
    //     * editOnFocus
    //     * editOnSelect
    // The first one renders the right selection but the second one uses
    // global `window.getSelection()` (which doesn't exist, as the editor lost focus)
    // to check if the editorState selection matches that one. As it doesn't, it renders
    // the selection in the first character of the first block.
    // Using `forceSelection` won't trigger those events and the selection will be correct.
    if (type === PopupTypes.Hidden) {
        newEditorState = EditorState.forceSelection(editorState, editorState.getSelection());
    }

    return {...state, editorState: newEditorState, popup: {type, data}};
};

type PopupType = keyof typeof PopupTypes;

const setMultiLinePopup = (state: IEditorStore, {type, data}: {type: PopupType, data: SelectionState }) =>
    processCells(
        state,
        (cells, numCols, numRows, i, j, withHeader, currentStyle, selection) => {
            const newData = {cells, numRows, numCols, withHeader};

            return {
                ...state,
                popup: {type, data},
                data: newData,
            };
        },
    );

function changeCase(state: IEditorStore, payload: {changeTo: ITextCase, selection: SelectionState}) {
    const getChangedText = (text: string) => {
        if (changeTo === 'uppercase') {
            return text.toUpperCase();
        } else if (changeTo === 'lowercase') {
            return text.toLowerCase();
        } else {
            assertNever(changeTo);
        }
    };

    const {selection, changeTo} = payload;
    const contentState = state.editorState.getCurrentContent();

    let ended = false;
    let started = false;
    const startKey = selection.getStartKey();
    const endKey = selection.getEndKey();

    const nextBlockMap = state.editorState.getCurrentContent().getBlockMap().map((block) => {
        const key = block.getKey();
        const from = key === startKey ? selection.getStartOffset() : 0;
        const to = key === endKey ? selection.getEndOffset() : block.getLength();

        if (key === startKey) {
            started = true;
        }

        if (ended === true || started === false) {
            return block;
        }

        const text = block.getText();
        const before = text.slice(0, from);
        const toReplace = text.slice(from, to);
        const after = text.slice(to, block.getLength());

        if (key === endKey) {
            ended = true;
        }

        return block.merge({
            text: before + getChangedText(toReplace) + after,
        });
    }) as ContentState;

    const nextContentState = contentState.merge({
        blockMap: nextBlockMap,
    }) as ContentState;

    const nextEditorState = EditorState.push(state.editorState, nextContentState, 'spellcheck-change');

    return onChange(state, EditorState.forceSelection(nextEditorState, selection));
}

const setCustomToolbar = (state: IEditorStore, style: IEditorStore['customToolbarStyle']): IEditorStore => ({
    ...state,
    customToolbarStyle: style,
});

export default toolbar;