rx/presenters

View on GitHub
views/mdc/assets/js/components/rich-text-area.js

Summary

Maintainability
A
1 hr
Test Coverage
import Quill from "quill";
import {HorizontalRuleBlot} from './rich-text-area/horizontal-rule-blot';
import {hookupComponents, VBaseComponent} from "./base-component";
import {eventHandlerMixin} from "./mixins/event-handler";
import {dirtyableMixin} from './mixins/dirtyable';

// These Blots will be registered with Quill.
const blots = [
    HorizontalRuleBlot,
];
const toolbarOptions = [
    ['bold', 'italic', 'underline', 'strike'],
    [{ 'color': [] }],
    [{ 'align': [] }],
    ['blockquote', 'horizontal-rule'],
    [{ 'list': 'ordered'}, { 'list': 'bullet' }],
    [{ 'script': 'sub'}, { 'script': 'super' }],
    [{ 'direction': 'rtl' }],
    [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
    [{ 'size': ['xx-small', false, 'large', 'x-large'] }],
    ['link', 'image', 'video'],
    ['clean']
];
const EMPTY_VALUE = '';

export function initRichTextArea(e) {
    console.debug('\tRich Text Area');
    hookupComponents(e, '.v-rich-text-area-container', VRichTextArea, null);
}

export class VRichTextArea extends dirtyableMixin(eventHandlerMixin(VBaseComponent)) {
    constructor(element, mdcComponent) {
        super(element, mdcComponent);

        configureQuill();
        registerBlots();

        this.quillWrapper = element.querySelector('.v-rich-text-area');
        this.quill = new Quill(this.quillWrapper, {
            modules: {toolbar: toolbarOptions},
            bounds: this.quillWrapper,
            theme: 'snow',
            placeholder: this.quillWrapper.dataset.placeholder
        });
        this.fixedUpContentElement = element.querySelector('.v-rich-text-area--fixed-up-content')
        this.quillEditor = this.quillWrapper.querySelector('.ql-editor');

        if (element.hasAttribute('disabled')) {
            this.quill.enable(false);
        }

        hookupCustomToolbarButtons(this);

        // Fix-ups:
        this.updateFixedContentElement();
        this.quill.on('text-change', () => this.updateFixedContentElement());

        adjustEditorStyles(this);
        this.originalValue = this.value();
    }

    prepareSubmit(params) {
        params.push(['rich_text_payload', 'true']);
        params.push([this.name(), this.value()]);
    }

    name() {
        return this.quillWrapper.dataset.name;
    }

    value() {
        const document = this.fixedUpContentElement.innerHTML;

        return this.quill.editor.isBlank() ? EMPTY_VALUE : document;
    }

    clear() {
        if (this.value() !== EMPTY_VALUE) {
            this.setValue(EMPTY_VALUE);
        }
    }

    reset() {
        this.setValue(this.originalValue);
    }

    setValue(value) {
        this.quill.root.innerHTML = value;
    }

    isDirty() {
        return this.dirtyable
            && this.value().localeCompare(this.originalValue) !== 0;
    }

    updateFixedContentElement() {
        const rawDocument = this.quill.root.innerHTML;

        this.fixedUpContentElement.innerHTML = convertLists(rawDocument);
    }
}

function adjustEditorStyles(richTextArea) {
    // The editor element is not created until Quill has been initialized, so
    // its styles must be adjusted dynamically post-construction.
    const initialHeight = richTextArea.element.dataset.initialHeight;

    richTextArea.quillEditor.style.height = initialHeight;
    richTextArea.quillEditor.style.minHeight = initialHeight;
}

const blotRegistry = new WeakSet();

function registerBlots() {
    for (const blot of blots) {
        if (blotRegistry.has(blot)) {
            continue;
        }

        // Set required Blot attributes:
        blot.blotName = blot.name;
        blot.tagName = blot.tag;

        Quill.register(blot);
        blotRegistry.add(blot)
    }
}

function hookupCustomToolbarButtons(vRichTextArea) {
    for (const blotClass of blots) {
        const {name, action} = blotClass;
        const buttons = vRichTextArea.element.querySelectorAll(`.ql-${name}`);

        for (const button of buttons) {
            // Invoke the Blot's action when button is clicked:
            button.addEventListener('click', (event) => {
                action(vRichTextArea.quill, event);
            });
        }
    }
}

function configureQuill() {
    // Inform Quill that it should decorate text objects with inline styles
    // instead of Quill CSS classes when modifying text:
    const sizeAttributor = Quill.import('attributors/style/size');
    sizeAttributor.whitelist = [
        'xx-small',
        'x-small',
        'small',
        'medium',
        'large',
        'x-large',
        'xx-large',
        false
    ];

    const styleAttributors = [
        sizeAttributor,
        Quill.import('attributors/style/align'),
        Quill.import('attributors/style/direction'),
    ];

    for (const attributor of styleAttributors) {
        Quill.register(attributor, true);
    }
}

// Quill 1 is not capable of generating structurally-sound nested lists. Instead
// of generated nested list elements, all list items are generated at the same
// level. Indentation and list item numbers are handled by various `indent` CSS
// classes and CSS counters. Eugh.
// see https://github.com/quilljs/quill/issues/979

// from https://github.com/quilljs/quill/issues/979#issuecomment-381151479.
function convertLists(richtext) {
    const tempEl = window.document.createElement('div');
    tempEl.setAttribute('style', 'display: none;');
    tempEl.innerHTML = richtext;

    ['ul','ol'].forEach((type) => {
        const startTag = `::start${type}::::/start${type}::`;
        const endTag = `::end${type}::::/end${type}::`;

        // Grab each list, and work on it in turn
        Array.from(tempEl.querySelectorAll(type)).forEach((outerListEl) => {
            const listChildren = Array.from(outerListEl.children).filter((el) => el.tagName === 'LI');

            // Account for the fact that the first li might not be at level 0
            const firstLi = listChildren[0];
            firstLi.before(startTag.repeat(getListLevel(firstLi)));

            // Now work through each li in this list
            listChildren.forEach((listEl, index) => {
                const currentLiLevel = getListLevel(listEl);
                if (index < listChildren.length - 1) {
                    const difference = getListLevel(listChildren[index + 1]) - currentLiLevel;

                    // we only need to add tags if the level is changing
                    if (difference > 0) {
                        listChildren[index + 1].before(startTag.repeat(difference));
                    } else if (difference < 0) {
                        listEl.after(endTag.repeat(-difference));
                    }
                } else {
                    listEl.after(endTag);
                }
            });
            outerListEl.after(endTag);
        });
    });

    //  Get the content in the element and replace the temporary tags with new ones
    let newContent = tempEl.innerHTML;
    newContent = newContent.replace(/::startul::::\/startul::/g, '<ul>');
    newContent = newContent.replace(/::endul::::\/endul::/g, '</ul>');
    newContent = newContent.replace(/::startol::::\/startol::/g, '<ol>');
    newContent = newContent.replace(/::endol::::\/endol::/g, '</ol>');

    tempEl.remove();
    return newContent;
}

function getListLevel(el) {
    const className = el.className || '0';
    return +className.replace(/[^\d]/g, '');
}