silegis-mg/editor-articulacao

View on GitHub
src/EditorArticulacaoController.js

Summary

Maintainability
F
3 days
Test Coverage
/* Copyright 2017 Assembleia Legislativa de Minas Gerais
 * 
 * This file is part of Editor-Articulacao.
 *
 * Editor-Articulacao is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, version 3.
 *
 * Editor-Articulacao is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Editor-Articulacao.  If not, see <http://www.gnu.org/licenses/>.
 */

import ContextoArticulacaoAtualizadoEvent from './eventos/ContextoArticulacaoAtualizadoEvent';
import ContextoArticulacao from './ContextoArticulacao';
import adicionarTransformacaoAutomatica from './transformacaoAutomatica/transformacaoAutomatica';
import hackChrome from './hacks/chrome';
import hackIE from './hacks/ie';
import polyfill from './hacks/polyfill';
import importarDeLexML from './lexml/importarDeLexML';
import exportarParaLexML from './lexml/exportarParaLexML';
import ClipboardController from './ClipboardController';
import criarControleAlteracao from './ControleAlteracao';
import css from './editor-articulacao-css.js';
import cssShadow from './editor-articulacao-css-shadow.js';
import ArticulacaoInvalidaException from './lexml/ArticulacaoInvalidaException';
import { encontrarDispositivoAnteriorDoTipo, encontrarDispositivoPosteriorDoTipo } from './util';
import ValidacaoController from './validacao/ValidacaoController';
import padrao from './opcoesPadrao';

var cssImportado = false;

/**
 * Controlador do editor de articulação.
 */
class EditorArticulacaoController {

    /**
     * Elemento do DOM que será utilizado como editor de articulação.
     * 
     * @param {Element} elemento Elemento que receberá o editor de articulação.
     * @param {Object} opcoes Opções do editor de articulação:
     *      - {Boolean} shadowDOM: Utiliza shadow-dom, se disponível (padrão: true).
     */
    constructor(elemento, opcoes) {
        if (!(elemento instanceof Element)) {
            throw 'Elemento não é um elemento do DOM.';
        }

        polyfill();

        let opcoesEfetivas = Object.create(padrao);
        Object.assign(opcoesEfetivas, opcoes);

        if (opcoes && opcoes.rotulo) {
            Object.setPrototypeOf(opcoesEfetivas.rotulo, padrao.rotulo);
        }

        this.opcoes = opcoesEfetivas;
        Object.freeze(opcoesEfetivas);
        Object.freeze(opcoesEfetivas.rotulo);
        Object.freeze(opcoesEfetivas.validacao);

        elemento = transformarEmEditor(elemento, this, opcoesEfetivas);

        Object.defineProperty(this, '_elemento', {
            value: elemento
        });

        this._handlers = [];

        if (!this.opcoes.somenteLeitura) {
            this._registrarEventos();

            if (opcoesEfetivas.transformacaoAutomatica) {
                adicionarTransformacaoAutomatica(this, elemento);
            }

            this.controleAlteracao = criarControleAlteracao(this);
            this.validacaoCtrl = new ValidacaoController(this.opcoes.validacao);
            this.clipboardCtrl = new ClipboardController(this, this.validacaoCtrl);

            // Executa hack se necessário.
            if (/Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor)) {
                hackChrome(this);
            }

            if (/Trident\//.test(navigator.userAgent)) {
                hackIE(this);
            }
        }

        this.limpar();
    }

    /**
     * Dispara evento no DOM. Deve-se usar este método, pois caso seja utilizado
     * ShadowDOM, este método é substituído para propagação externa de evento.
     * 
     * @param {EventoInterno} eventoInterno 
     */
    dispatchEvent(eventoInterno) {
        this._elemento.dispatchEvent(eventoInterno.getCustomEvent());
    }

    getSelection() {
        return document.getSelection();
    }

    get vazio() {
        return !this._elemento.firstElementChild || this._elemento.firstElementChild === this._elemento.lastElementChild && this._elemento.textContent === '';
    }

    get lexml() {
        try {
            return this.vazio ? document.createDocumentFragment() : exportarParaLexML(this._elemento, this.opcoes.rotulo);
        } catch (e) {
            if (e instanceof ArticulacaoInvalidaException) {
                for (let filho = this._elemento.firstElementChild; filho; filho = filho.nextElementSibling) {
                    this._normalizarDispositivo(filho);
                }

                return exportarParaLexML(this._elemento);
            }

            throw e;
        }
    }

    set lexml(valor) {
        let articulacao = importarDeLexML(valor);

        this._elemento.innerHTML = '';
        this._elemento.appendChild(articulacao);

        if (this.vazio) {
            this.limpar();
        }

        if (!this.opcoes.somenteLeitura) {
            this.controleAlteracao.comprometer();

            if (this.opcoes.validarAoAtribuir) {
                for (let dispositivo = this._elemento.firstElementChild; dispositivo; dispositivo = dispositivo.nextElementSibling) {
                    this.validacaoCtrl.validar(dispositivo);
                }
            }
        }
    }

    limpar() {
        this._elemento.innerHTML = '<p data-tipo="artigo"><br></p>';
    }

    get lexmlString() {
        var xml = this.lexml;
        var xmlSerializer = new XMLSerializer();
        var resultado = xmlSerializer.serializeToString(xml);

        return this.opcoes.escaparXML ? escaparXml(resultado) : resultado;
    }

    set lexmlString(valor) {
        this.lexml = valor;
    }

    get alterado() {
        return this.controleAlteracao.alterado;
    }

    /**
     * Adiciona tratamento de eventos no DOM, que pode ser removido em seguida
     * por meio do método "desregistrar".
     */
    _registrarEventos() {
        let eventHandler = this._cursorEventHandler.bind(this);
        let eventos = ['focus', 'keyup', 'mousedown', 'touchstart', 'mouseup', 'touchend'];

        eventos.forEach(evento => this.registrarEventListener(evento, eventHandler));
        this.registrarEventListener('keydown', this._keyDownEventHandler.bind(this));

        this.registrarEventListener('blur', e => {
            if (!this.opcoes.somenteLeitura) {
                if (this.vazio) {
                    this.limpar();
                }

                let contexto = this.contexto;

                if (contexto && contexto.cursor.dispositivo) {
                    this.validacaoCtrl.validar(this.contexto.cursor.dispositivo);
                }
            }
        });
    }

    /**
     * Adiciona um tratamento de evento no elemento do editor no DOM.
     * 
     * @param {*} evento Evento a ser capturado.
     * @param {*} listener
     * @param {*} useCapture 
     */
    registrarEventListener(evento, listener, useCapture) {
        this._handlers.push({ evento: evento, handler: listener });
        this._elemento.addEventListener(evento, listener, useCapture);
    }

    desregistrar() {
        this._handlers.forEach(registro => this._elemento.removeEventListener(registro.evento, registro.handler));
        this._handlers = [];
    }

    /**
     * Trata evento de alteração de cursor.
     */
    _cursorEventHandler(event) {
        this.atualizarContexto();
        this._normalizarContexto();

        if (event instanceof KeyboardEvent && event.key && event.key.length === 1 && this.contexto.cursor.dispositivo && this.contexto.cursor.dispositivo.hasAttribute('data-invalido')) {
            this.contexto.cursor.dispositivo.removeAttribute('data-invalido');
        }
    }

    _keyDownEventHandler(event) {
        if (!this.contexto || !this.contexto.cursor.elemento) {
            this._cursorEventHandler(event);
        }

        let elementoSelecionado = obterSelecao(this);

        if (!this.contexto || elementoSelecionado !== this.contexto.cursor.elemento) {
            this._cursorEventHandler(event);
        }
    }

    _normalizarContexto() {
        if (!this.contexto) {
            return;
        }

        let dispositivo = this.contexto.cursor.dispositivo;

        if (dispositivo) {
            this._normalizarDispositivo(dispositivo.previousElementSibling);
            this._normalizarDispositivo(dispositivo, this.contexto);
            this._normalizarDispositivo(dispositivo.nextElementSibling);

            if (!this.opcoes.somenteLeitura) {
                this.validacaoCtrl.validar(dispositivo.previousElementSibling);
                this.validacaoCtrl.validar(dispositivo.nextElementSibling);
            }
        }
    }

    /**
     * Atualiza a variável de análise do contexto do cursor.
     */
    atualizarContexto() {
        var elementoSelecionado = obterSelecao(this);

        if (!elementoSelecionado) {
            if (!this.contexto) {
                return;
            }

            elementoSelecionado = this.contexto.cursor.elemento;
        }

        /* Se a seleção estiver no container e não houver nenhum conteúdo,
         * então devemos recriar o conteúdo mínimo.
         */
        if (elementoSelecionado === this._elemento && (!this._elemento.firstElementChild || this._elemento.firstElementChild === this._elemento.lastElementChild && this._elemento.firstElementChild.tagName === 'BR')) {
            this.limpar();
            elementoSelecionado = this._elemento.firstElementChild;

            let selecao = this.getSelection();
            selecao.removeAllRanges();
            let range = document.createRange();

            range.selectNodeContents(elementoSelecionado);
            selecao.addRange(range);
        }

        var novoCalculo = new ContextoArticulacao(this._elemento, elementoSelecionado);

        if (!this.contexto || this.contexto.cursor.elemento !== elementoSelecionado || this.contexto.comparar(novoCalculo)) {
            // Realiza a validação do cursor anterior.
            if (this.contexto && this.contexto.cursor.dispositivo && !this.opcoes.somenteLeitura) {
                this.validacaoCtrl.validar(this.contexto.cursor.dispositivo);
            }

            this.contexto = novoCalculo;
            this.dispatchEvent(new ContextoArticulacaoAtualizadoEvent(novoCalculo));
        }

        return novoCalculo;
    }

    /**
     * Altera o tipo do dispositivo selecionado.
     * 
     * @param {String} novoTipo Novo tipo do dispositivo.
     */
    alterarTipoDispositivoSelecionado(novoTipo) {
        this.atualizarContexto();

        if (!this.contexto) {
            throw 'Não há contexto atual.';
        }

        if (!this.contexto.permissoes[novoTipo]) {
            throw 'Tipo não permitido: ' + novoTipo;
        }

        let dispositivo = this.contexto.cursor.dispositivo;

        this._definirTipo(dispositivo, novoTipo);

        // Se a seleção incluir mais de um dispositivo, vamos alterá-los também.
        let selecao = this.getSelection();
        let range = selecao && selecao.rangeCount > 0 ? selecao.getRangeAt(0) : null;
        let endContainer = range ? range.endContainer : null;

        if (endContainer) {
            /* Se o container final não estiver no mesmo nível (por exemplo,
             * um nó textual ou até mesmo uma tag de itálico dentro do
             * dispositivo), então considera-se como endContainer o dispositivo
             * ancestral, por meio da verificação do atributo "data-tipo".
             */
            while (endContainer !== this._elemento && (endContainer.nodeType !== Node.ELEMENT_NODE || !endContainer.hasAttribute('data-tipo'))) {
                endContainer = endContainer.parentElement;
            }

            /* Altera o tipo para todos os dispositivos seguintes até encontrar
             * o container final.
             */
            while (dispositivo !== endContainer && dispositivo) {
                dispositivo = dispositivo.nextElementSibling;
                this._definirTipo(dispositivo, novoTipo);
            }
        }

        this.atualizarContexto();
    }

    /**
     * Altera o tipo de um dispositivo, sem qualquer validação.
     * 
     * @param {Element} dispositivo 
     * @param {String} novoTipo 
     */
    _definirTipo(dispositivo, novoTipo) {
        let tipoAnterior = dispositivo.getAttribute('data-tipo');

        if (tipoAnterior !== novoTipo) {
            dispositivo.setAttribute('data-tipo', novoTipo);
            dispositivo.classList.remove('unico');
            
            try {
                this._normalizarContexto();
            } finally {
                this.controleAlteracao.alterado = true;
            }
        }
    }

    /**
     * Normaliza o dispositivo, corrigindo eventuais inconsistências.
     * 
     * @param {Element} dispositivo 
     * @param {ContextoArticulacao} contexto 
     */
    _normalizarDispositivo(dispositivo, contexto) {
        while (dispositivo && !dispositivo.hasAttribute('data-tipo')) {
            dispositivo = dispositivo.parentElement;
        }

        if (!dispositivo) {
            return;
        }

        if (!contexto) {
            contexto = new ContextoArticulacao(this._elemento, dispositivo);
        } else if (!(contexto instanceof ContextoArticulacao)) {
            throw 'Conexto não é instância de ContextoArticulacao.';
        }

        if (contexto && !contexto.permissoes[dispositivo.getAttribute('data-tipo')]) {
            try {
                let anterior = dispositivo.previousElementSibling;
                this._normalizarDispositivo(anterior);
                dispositivo.setAttribute('data-tipo', obterTipoValido(anterior.getAttribute('data-tipo') || 'continuacao', contexto.permissoes));
                this._normalizarDispositivo(dispositivo.nextElementSibling);
            } catch (e) {
                dispositivo.setAttribute('data-tipo', 'artigo');
                console.error(e);
            }
        }

        if (dispositivo.getAttribute('data-tipo') === 'paragrafo') {
            this._normalizarParagrafo(dispositivo);
        }
    }

    /**
     * Normaliza o dispositivo que é ou foi um parágrafo.
     * Este método irá verificar a existência de parágrafo único, ajustando css.
     * 
     * @param {Element} dispositivo Parágrafo
     */
    _normalizarParagrafo(dispositivo) {
        let anterior = encontrarDispositivoAnteriorDoTipo(dispositivo, ['artigo', 'paragrafo']);
        let posterior = encontrarDispositivoPosteriorDoTipo(dispositivo, ['artigo', 'paragrafo']);

        if (dispositivo.getAttribute('data-tipo') === 'paragrafo' || dispositivo.getAttribute('data-tipo') === 'continuacao') {
            dispositivo.classList.toggle('unico',
                ((!anterior || anterior.getAttribute('data-tipo') !== 'paragrafo') &&
                    (!posterior || posterior.getAttribute('data-tipo') !== 'paragrafo')));

            if (anterior && anterior.getAttribute('data-tipo') === 'paragrafo') {
                anterior.classList.remove('unico');
            }

            if (posterior && posterior.getAttribute('data-tipo') === 'paragrafo') {
                posterior.classList.remove('unico');
            }

            // Se o parágrafo anterior é sem ordinal, então este também é.
            if (anterior && anterior.classList.contains('semOrdinal')) {
                dispositivo.classList.add('semOrdinal');
            } else {
                /* Se o parágrafo anterior tem ordinal, então vamos contar quantos parágrafos tem
                 * para decidir se este também deve ter. Esta contagem não é feita por artigo,
                 * pois é possível resolver esta questão usando CSS. No caso de parágrafo,
                 * a contagem de parágrafos usando o seletor ~ é influenciada por parágrafos
                 * de outros artigos.
                 */
                let i, aux;

                for (i = 1, aux = anterior; i < 10 && aux && aux.getAttribute('data-tipo') === 'paragrafo'; i++) {
                    aux = encontrarDispositivoAnteriorDoTipo(aux, ['artigo', 'paragrafo']);
                }

                dispositivo.classList.toggle('semOrdinal', i >= 10);
            }
        } else if (anterior && anterior.getAttribute('data-tipo') === 'paragrafo' && (!posterior || posterior.getAttribute('data-tipo') !== 'paragrafo')) {
            anterior.classList.add('unico');
        } else if ((!anterior || anterior.getAttribute('data-tipo') !== 'paragrafo') && posterior && posterior.getAttribute('data-tipo') === 'paragrafo') {
            this._normalizarParagrafo(posterior);
        }
    }
}

/**
 * Obtém a seleção atual.
 * 
 * @returns {Element} Elemento selecionado.
 */
function obterSelecao(ctrl) {
    var selecao = ctrl.getSelection();
    var range = selecao && selecao.rangeCount > 0 ? selecao.getRangeAt(0) : null;

    if (range) {
        let startContainer = range ? range.startContainer : null;

        if (!startContainer) {
            return null;
        }

        if (startContainer.nodeType !== Node.ELEMENT_NODE || startContainer.nodeName === 'BR') {
            startContainer = startContainer.parentNode;
        }

        if (startContainer === ctrl._elemento) {
            let refazerSelecao = true;

            // A seleção deveria estar em algum dispositivo.
            if (!startContainer.firstElementChild || !startContainer.firstElementChild.hasAttribute('data-tipo')) {
                ctrl.limpar();
                startContainer = ctrl._elemento.firstElementChild;
            } else if (range.collapsed && range.startOffset === startContainer.childNodes.length) {
                // Se está no final do container da articulação, então vamos para o último elemento na hierarquia.
                do {
                    startContainer = startContainer.lastElementChild;
                } while (startContainer.lastElementChild);
            } else if (range.collapsed && range.startOffset === 0) {
                // Se está no início do container da articulação, então vamos para o primeiro elemento no último nível da hierarquia.
                do {
                    startContainer = startContainer.firstElementChild;
                } while (startContainer.firstElementChild);
            } else {
                refazerSelecao = false;
            }

            if (refazerSelecao) {
                selecao.removeAllRanges();
                range = document.createRange();
                range.setStart(startContainer, 0);
                selecao.addRange(range);
            }

            return startContainer;
        } else if (startContainer.compareDocumentPosition(ctrl._elemento) & Node.DOCUMENT_POSITION_CONTAINS) {
            // Garante que a seleção está dentro do editor de articulação.
            return startContainer;
        }
    }

    return null;
}

/**
 * Obtém um tipo válido, a partir de um tipo desejado.
 * 
 * @param {String} tipoDesejado 
 * @param {Object} permissoes Permissões do contexto atual.
 * @returns {String} Tipo válido.
 */
function obterTipoValido(tipoDesejado, permissoes) {
    if (tipoDesejado && !permissoes[tipoDesejado]) {
        return obterTipoValido({
            titulo: 'capitulo',
            capitulo: 'artigo',
            secao: 'artigo',
            subsecao: 'artigo'
        }[tipoDesejado], permissoes);
    }

    return tipoDesejado || 'artigo';
}

/**
 * Substitui caracteres de código alto por códigos unicode.
 * Substitui entidades não suportadas no XML por códigos unicode.
 *
 * @param {String} xml XML original.
 * @returns {String} XML escapado.
 */
function escaparXml(xml) {
    return xml
        // Remove caracteres de controle C0 não permitidos no XML 1.0
        .replace(/[\u0001-\u0008\u000B-\u000C\u000E\u001F]/gm, '')        // eslint-disable-line no-control-regex
        // Converte caracteres superiores a 160
        .replace(/[\u00A0-\u9999]/gm, function (i) {
            return '&#' + i.charCodeAt(0) + ';';    // Converte em unicode escapado.
        });
}

function transformarEmEditor(elemento, editorCtrl, opcoes) {
    let style = document.createElement('style');
    style.innerHTML = css.toString().replace(/\${(.+?)}/g, (m, valor) => opcoes.rotulo[valor]);

    /* Se houver suporte ao shadow-dom, então vamos usá-lo
     * para garantir o isolamento da árvore interna do componente
     * e possíveis problemas com CSS.
     */
    if (opcoes.shadowDOM && 'attachShadow' in elemento) {
        let shadowStyle = document.createElement('style');
        shadowStyle.innerHTML = cssShadow.toString();

        let shadow = elemento.attachShadow({ mode: (typeof opcoes.shadowDOM === 'string' ? opcoes.shadowDOM : 'open') });

        editorCtrl.dispatchEvent = elemento.dispatchEvent.bind(elemento);
        editorCtrl.getSelection = () => shadow.getSelection();

        let novoElemento = document.createElement('div');
        novoElemento.contentEditable = !opcoes.somenteLeitura;
        novoElemento.spellcheck = elemento.spellcheck;
        novoElemento.classList.add('silegismg-editor-articulacao');

        shadow.appendChild(style);
        shadow.appendChild(shadowStyle);
        shadow.appendChild(novoElemento);

        elemento.addEventListener('focus', focusEvent => novoElemento.focus());
        elemento.focus = function () { novoElemento.focus(); };

        return novoElemento;
    } else {
        elemento.contentEditable = !opcoes.somenteLeitura;
        elemento.classList.add('silegismg-editor-articulacao');

        if (!cssImportado) {
            let head = document.querySelector('head');

            if (head) {
                head.appendChild(style);
            } else {
                document.body.appendChild(style);
            }

            cssImportado = true;
        }

        return elemento;
    }
}

export default EditorArticulacaoController;