superdesk/superdesk-client-core

View on GitHub
scripts/core/editor3/components/handlePastedText.ts

Summary

Maintainability
C
7 hrs
Test Coverage
/* eslint-disable max-depth */

import {EditorState, ContentState, Modifier, genKey, CharacterMetadata, ContentBlock, DraftHandleValue} from 'draft-js';
import {List, OrderedMap} from 'immutable';
import {getContentStateFromHtml} from '../html/from-html';
import * as Suggestions from '../helpers/suggestions';
import {sanitizeContent, inlineStyles} from '../helpers/inlineStyles';
import {getAllCustomDataFromEditor, setAllCustomDataForEditor} from '../helpers/editor3CustomData';
import {getCurrentAuthor} from '../helpers/author';
import {htmlComesFromDraftjsEditor} from '../helpers/htmlComesFromDraftjsEditor';
import {EDITOR_GLOBAL_REFS} from 'core/editor3/components/Editor3Component';
import {escape as escapeHtml} from 'lodash';

function removeMediaFromHtml(htmlString): string {
    const element = document.createElement('div');

    element.innerHTML = htmlString;

    Array.from(element.querySelectorAll('img,audio,video')).forEach((mediaElement) => {
        mediaElement.remove();
    });

    return element.innerHTML;
}

function pasteContentFromOpenEditor(
    html: string,
    editorState: EditorState,
    editorKey: string,
    onChange: (e: EditorState) => void,
    editorFormat: Array<string>,
): DraftHandleValue {
    if (html.includes(editorKey)) { // comes from the same editor
        return 'not-handled';
    }

    for (const key in window[EDITOR_GLOBAL_REFS]) {
        if (html.includes(key)) {
            const editor = window[EDITOR_GLOBAL_REFS][key];

            if (editor) {
                const internalClipboard = editor.getClipboard();

                if (internalClipboard) {
                    const blocksArray = [];

                    internalClipboard.forEach((b) => blocksArray.push(b));

                    const contentState = ContentState.createFromBlockArray(blocksArray);
                    const editorWithContent = insertContentInState(editorState, contentState, editorFormat);

                    onChange(editorWithContent);

                    return 'handled';
                }
            }
        }
    }

    return 'not-handled';
}

// preserve line breaks when pasting or forcing plain text
// \r are important for draft convertFromHTML to preserve initial spaces on each line
export const createHtmlFromText = (text: string): string =>
    escapeHtml(text).split('\n').map((line) => `<p>${line}</p>`).join('');

/**
 * @ngdoc method
 * @name handlePastedText
 * @param {string} text Text content of paste.
 * @param {string=} _html HTML content of paste.
 * @returns {Boolean} True if this method took paste into its own hands.
 * @description Handles pasting into the editor, in cases where the content contains
 * atomic blocks that need special handling in editor3.
 */
export function handlePastedText(text: string, _html: string): DraftHandleValue {
    const author = getCurrentAuthor();
    let html = _html;

    if (typeof html === 'string') {
        html = removeMediaFromHtml(html);
    }

    const {editorState, suggestingMode, onPasteFromSuggestingMode, onChange, editorFormat} = this.props;

    if (!html && !text) {
        return 'handled';
    }

    if (text != null && (this.props.cleanPastedHtml || html == null)) {
        html = createHtmlFromText(text);
    }

    if (suggestingMode) {
        if (!Suggestions.allowEditSuggestionOnLeft(editorState, author)
            && !Suggestions.allowEditSuggestionOnRight(editorState, author)) {
            return 'handled';
        }

        const content = html ? getContentStateFromHtml(html) : ContentState.createFromText(text);

        onPasteFromSuggestingMode(content);
        return 'handled';
    }

    if (html &&
        pasteContentFromOpenEditor(html, editorState, this.editorKey, onChange, editorFormat) === 'handled') {
        return 'handled';
    }

    if (htmlComesFromDraftjsEditor(html, false)) {
        return 'not-handled';
    }

    return processPastedHtml(html || text, editorState, onChange, editorFormat);
}

export function insertContentInState(
    editorState: EditorState,
    pastedContent: ContentState,
    editorFormat: Array<string>): EditorState {
    let _pastedContent = pastedContent;
    const blockMap = _pastedContent.getBlockMap();
    const hasAtomicBlocks = blockMap.some((block) => block.getType() === 'atomic');
    const acceptedInlineStyles =
            Object.keys(inlineStyles)
                .filter((style) => editorFormat.includes(style))
                .map((style) => inlineStyles[style]);

    let contentState = editorState.getCurrentContent();
    let selection = editorState.getSelection();
    let blocks = [];

    if (hasAtomicBlocks) {
        contentState = Modifier.splitBlock(editorState.getCurrentContent(), editorState.getSelection());
        selection = contentState.getSelectionAfter();
    }

    _pastedContent = sanitizeContent(EditorState.createWithContent(_pastedContent), acceptedInlineStyles)
        .getCurrentContent();

    blockMap.forEach((block) => {
        if (!hasAtomicBlocks || block.getType() !== 'atomic') {
            return blocks.push(block);
        }

        const entityKey = block.getEntityAt(0);
        const entity = _pastedContent.getEntity(entityKey);

        contentState = contentState.addEntity(entity);

        blocks = blocks.concat(
            atomicBlock(block.getData(), contentState.getLastCreatedEntityKey()),
        );
    });

    if (hasAtomicBlocks) {
        contentState = Modifier.setBlockType(contentState, selection, 'atomic');

        blocks = blocks.concat(emptyBlock()); // add empty block to ensure writting afterwards
    }

    const newBlockMap = OrderedMap<string, ContentBlock>(blocks.map((b) => ([b.getKey(), b])));
    const customData = getAllCustomDataFromEditor(editorState);

    const newContent = Modifier.replaceWithFragment(
        editorState.getCurrentContent(),
        editorState.getSelection(),
        newBlockMap,
    );

    let nextEditorState = EditorState.push(editorState, newContent, 'insert-fragment');

    // for the first block recover the initial block data because on replaceWithFragment the block data is
    // replaced with the data from pasted fragment
    nextEditorState = setAllCustomDataForEditor(nextEditorState, customData);

    return nextEditorState;
}

// Checks if there are atomic blocks in the paste content. If there are, we need to set
// the 'atomic' block type using the Modifier tool and add these entities to the
// contentState.
function processPastedHtml(
    html: string,
    editorState: EditorState,
    onChange: (e: EditorState) => void,
    editorFormat: Array<string>): DraftHandleValue {
    const pastedContent = getContentStateFromHtml(html);

    const editorWithPastedText = insertContentInState(
        editorState,
        pastedContent,
        editorFormat,
    );

    onChange(editorWithPastedText);

    return 'handled';
}

// Returns an empty block.
const emptyBlock = () => new ContentBlock({
    key: genKey(), type: 'unstyled', text: '', characterList: List(),
});

// Returns an atomic block with the given data, linked to the given entity key.
const atomicBlock = (data, entity) => new ContentBlock({
    key: genKey(), type: 'atomic', text: ' ',
    characterList: List([CharacterMetadata.create({entity})]),
    data: data,
});