BookStackApp/BookStack

View on GitHub
resources/js/wysiwyg/drop-paste-handling.js

Summary

Maintainability
A
2 hrs
Test Coverage
import {Clipboard} from '../services/clipboard';

let wrap;
let draggedContentEditable;

function hasTextContent(node) {
    return node && !!(node.textContent || node.innerText);
}

/**
 * Upload an image file to the server
 * @param {File} file
 * @param {int} pageId
 */
async function uploadImageFile(file, pageId) {
    if (file === null || file.type.indexOf('image') !== 0) {
        throw new Error('Not an image file');
    }

    const remoteFilename = file.name || `image-${Date.now()}.png`;
    const formData = new FormData();
    formData.append('file', file, remoteFilename);
    formData.append('uploaded_to', pageId);

    const resp = await window.$http.post(window.baseUrl('/images/gallery'), formData);
    return resp.data;
}

/**
 * Handle pasting images from clipboard.
 * @param {Editor} editor
 * @param {WysiwygConfigOptions} options
 * @param {ClipboardEvent|DragEvent} event
 */
function paste(editor, options, event) {
    const clipboard = new Clipboard(event.clipboardData || event.dataTransfer);

    // Don't handle the event ourselves if no items exist of contains table-looking data
    if (!clipboard.hasItems() || clipboard.containsTabularData()) {
        return;
    }

    const images = clipboard.getImages();
    for (const imageFile of images) {
        const id = `image-${Math.random().toString(16).slice(2)}`;
        const loadingImage = window.baseUrl('/loading.gif');
        event.preventDefault();

        setTimeout(() => {
            editor.insertContent(`<p><img src="${loadingImage}" id="${id}"></p>`);

            uploadImageFile(imageFile, options.pageId).then(resp => {
                const safeName = resp.name.replace(/"/g, '');
                const newImageHtml = `<img src="${resp.thumbs.display}" alt="${safeName}" />`;

                const newEl = editor.dom.create('a', {
                    target: '_blank',
                    href: resp.url,
                }, newImageHtml);

                editor.dom.replace(newEl, id);
            }).catch(err => {
                editor.dom.remove(id);
                window.$events.error(err?.data?.message || options.translations.imageUploadErrorText);
                console.error(err);
            });
        }, 10);
    }
}

/**
 * @param {Editor} editor
 */
function dragStart(editor) {
    const node = editor.selection.getNode();

    if (node.nodeName === 'IMG') {
        wrap = editor.dom.getParent(node, '.mceTemp');

        if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) {
            wrap = node.parentNode;
        }
    }

    // Track dragged contenteditable blocks
    if (node.hasAttribute('contenteditable') && node.getAttribute('contenteditable') === 'false') {
        draggedContentEditable = node;
    }
}

/**
 * @param {Editor} editor
 * @param {WysiwygConfigOptions} options
 * @param {DragEvent} event
 */
function drop(editor, options, event) {
    const {dom} = editor;
    const rng = window.tinymce.dom.RangeUtils.getCaretRangeFromPoint(
        event.clientX,
        event.clientY,
        editor.getDoc(),
    );

    // Template insertion
    const templateId = event.dataTransfer && event.dataTransfer.getData('bookstack/template');
    if (templateId) {
        event.preventDefault();
        window.$http.get(`/templates/${templateId}`).then(resp => {
            editor.selection.setRng(rng);
            editor.undoManager.transact(() => {
                editor.execCommand('mceInsertContent', false, resp.data.html);
            });
        });
    }

    // Don't allow anything to be dropped in a captioned image.
    if (dom.getParent(rng.startContainer, '.mceTemp')) {
        event.preventDefault();
    } else if (wrap) {
        event.preventDefault();

        editor.undoManager.transact(() => {
            editor.selection.setRng(rng);
            editor.selection.setNode(wrap);
            dom.remove(wrap);
        });
    }

    // Handle contenteditable section drop
    if (!event.isDefaultPrevented() && draggedContentEditable) {
        event.preventDefault();
        editor.undoManager.transact(() => {
            const selectedNode = editor.selection.getNode();
            const range = editor.selection.getRng();
            const selectedNodeRoot = selectedNode.closest('body > *');
            if (range.startOffset > (range.startContainer.length / 2)) {
                selectedNodeRoot.after(draggedContentEditable);
            } else {
                selectedNodeRoot.before(draggedContentEditable);
            }
        });
    }

    // Handle image insert
    if (!event.isDefaultPrevented()) {
        paste(editor, options, event);
    }

    wrap = null;
}

/**
 * @param {Editor} editor
 * @param {DragEvent} event
 */
function dragOver(editor, event) {
    // This custom handling essentially emulates the default TinyMCE behaviour while allowing us
    // to specifically call preventDefault on the event to allow the drop of custom elements.
    event.preventDefault();
    editor.focus();
    const rangeUtils = window.tinymce.dom.RangeUtils;
    const range = rangeUtils.getCaretRangeFromPoint(event.clientX ?? 0, event.clientY ?? 0, editor.getDoc());
    editor.selection.setRng(range);
}

/**
 * @param {Editor} editor
 * @param {WysiwygConfigOptions} options
 */
export function listenForDragAndPaste(editor, options) {
    editor.on('dragover', event => dragOver(editor, event));
    editor.on('dragstart', () => dragStart(editor));
    editor.on('drop', event => drop(editor, options, event));
    editor.on('paste', event => paste(editor, options, event));
}