silegis-mg/editor-articulacao

View on GitHub
src/hacks/chrome.js

Summary

Maintainability
B
6 hrs
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 { interceptar } from './interceptador';

function hackChrome(controller) {
    interceptar(controller, 'alterarTipoDispositivoSelecionado', hackAlterarTipo);

    controller.registrarEventListener('keydown', event => hackInterceptarKeydown(event, controller));
    
    /* A partir do Chrome 62, começou a ter problema no foco após limpar o editor.
     * O problema foi evitado adicionando um \n após o <br> ou substituindo o conteúdo
     * por "&nbsp;"" como feito no IE.
     */
    controller.limpar = function() {
        this._elemento.innerHTML = '<p data-tipo="artigo" style="min-height: 1em;"><br>\n</p>';
    };
}

/**
 * O chrome faz cache do counter-reset e, quando um counter-reset é introduzido
 * (por mudança de css), os elementos seguintes não são afetados.
 * 
 * @param {EditorArticulacaoController} ctrl 
 * @param {function} metodo
 * @param {Array} argumentos 
 */
function hackAlterarTipo(ctrl, metodo, argumentos) {
    let dispositivo = ctrl.contexto.cursor.dispositivo;
    let contadoresAntigos = extrairContadores(dispositivo);
    let retorno  = metodo.apply(ctrl, argumentos);
    let contadoresNovos = extrairContadores(dispositivo, contadoresAntigos.hash);

    /* Para cada contador novo, vamos redefinir o tipo dos próximos elementos,
     * até que se encontre em um elemento antigo o reinício do contador em questão.
     */
    for (let i = 0, proximo = ctrl.contexto.cursor.dispositivo.nextElementSibling; proximo && i < contadoresNovos.total; proximo = proximo.nextElementSibling) {
        let tipo = proximo.getAttribute('data-tipo');
        let contadoresProximo = extrairContadores(proximo);

        if (contadoresNovos.hash[tipo]) {
            redefinirContador(proximo);
        }

        // Não há necessidade de reiniciar contadores quando o próximo já o reiniciava.
        for (let contador in contadoresProximo.hash) {
            if (contadoresNovos.hash[contador]) {
                contadoresNovos.hash[contador] = false;
                i++;
            }
        }
    }

    return retorno;
}

/**
 * Extrai os contadores reinciiados por este elemento.
 * 
 * @param {Element} dispositivo 
 * @param {Object} contadoresDesconsiderar Hash contendo os contadores que não deverão ser considerados.
 */
function extrairContadores(dispositivo, contadoresDesconsiderar) {
    let counterReset = getComputedStyle(dispositivo).counterReset;
    let contadores = {};
    let cnt = 0;

    for (let regexp = /(.+?)\s\d+\s*/g, m = regexp.exec(counterReset); m; m = regexp.exec(counterReset)) {
        let contador = m[1];

        if (!contadoresDesconsiderar || !contadoresDesconsiderar[contador]) {
            contadores[contador] = true;
            cnt++;
        }
    }

    return { total: cnt, hash: contadores };
}

/**
 * Efetua a redefinição do contador.
 * 
 * @param {Element} dispositivo 
 */
function redefinirContador(dispositivo) {
    let tipo = dispositivo.getAttribute('data-tipo');
    dispositivo.removeAttribute('data-tipo');
    setTimeout(() => dispositivo.setAttribute('data-tipo', tipo));
}

/**
 * O Chrome tem um problema de desempenho que fica evidente ao importar
 * o LexML da Constituição Federal, selecionar todo o texto no editor
 * de articulação, e pressionar qualquer tecla que iria remover o texto
 * selecionado (seja por exclusão ou substituição).
 * 
 * Para cada elemento no DOM a ser excluído ou substituído, o navegador
 * processa os estilos do próximo elemento (conforme ferramenta de profile
 * do próprio navegador), tornando a operação O(n²). Para contornar o problema,
 * realizamos a exclusão do conteúdo via API, interceptando o keydown.
 * 
 * @param {KeyboardEvent} keyboardEvent 
 * @param {EditorArticulacaoController} editorCtrl 
 */
function hackInterceptarKeydown(keyboardEvent, editorCtrl) {
    /* Somente serão tratadas as alterações de conteúdo. Portanto,
     * se houver qualquer tecla modificativa (ctrl, alt ou meta),
     * o evento será ignorado. O evento só será tratado se a tecla for
     * de conteúdo (letra, número ou enter), ou remoção (delete, backspace).
     */
    if (!keyboardEvent.ctrlKey && !keyboardEvent.altKey && !keyboardEvent.metaKey &&
        keyboardEvent.key.length === 1 || keyboardEvent.key === 'Delete' || keyboardEvent.key === 'Backspace' ||
        keyboardEvent.key === 'Enter') {

        let selection = editorCtrl.getSelection();
        let range = selection.getRangeAt(0);

        // Se não houver nada selecionado, então não há problema de desempenho.
        if (!range.collapsed) {
            let inicio = range.startContainer.parentNode;
            let final = range.endContainer.parentNode;

            range.deleteContents();

            /* Se a tecla for de remoção, então evitamos a ação padrão.
             * Entretanto, se for de conteúdo, então deixamos que a ação
             * padrão seja executada, para que o novo conteúdo seja inserido
             * no lugar do conteúdo excluído.
             */
            if (keyboardEvent.key === 'Delete' || keyboardEvent.key === 'Backspace') {
                keyboardEvent.preventDefault();
            }

            /* O range da seleção, ainda que seja de todo o conteúdo, não abrange
             * o elemento inicial e o final. Isto é, se o usuário selecionar tudo
             * usando Ctrl+A e prosseguir com a exclusão com a tecla Delete, 
             * o conteúdo selecionado correpsonderá ao primeiro nó textual do primeiro
             * P até o último nó textual do útlimo P.
             * 
             *        <p><!-- início da seleção -->nó textual</p>
             *        <p>nó textual intermediário</p>
             *        <p>último nó textual<-- final da seleção --></p>
             * 
             * Assim, a exclusão irá remover todos os nós textuais e os elementos intermediários,
             * mantendo, entretanto, o primeiro e último elemento (veja ilustração acima,
             * em que a seleção foi demarcada em comentário).
             * 
             * Para contornar esta situação, realizamos manualmente a exclusão dos elementos,
             * se o elemento inicial da seleção for diferente do final, condicionando
             * a exclusão à presença de conteúdo. Deve-se executar o procedimento somente
             * se o início for diferente do final, pois a seleção pode ser apenas de
             * conteúdo intermediário, como ilustrado a seguir:
             * 
             *      <p>este <!-- início da seleção -->é um <!-- final da seleção -->exemplo.</p>
             */
            if (inicio !== final) {
                if (inicio != editorCtrl._elemento && inicio.textContent.length === 0) {
                    inicio.remove();
                }

                if (final != editorCtrl._elemento && final.textContent.length === 0) {
                    final.remove();
                }

                /* Caso todo o conteúdo estivesse selecionado, o editor de articulação
                    * passará a ter nenhum elemento neste momento. Neste caso,
                    * deve-se recriar o conteúdo mínimo.
                    */
                if (editorCtrl._elemento.children.length === 0) {
                    editorCtrl.limpar();
                }
            }
            // ... mas se a seleção for todo o conteúdo de um único elemento...
            else if (inicio.textContent.length === 0 && inicio.children.length === 0) {
                /* então deve-se garantir o conteúdo mínimo, para que o cursor do parágrafo
                    * fique posicionado corretamente.
                    */
                inicio.innerHTML = '<br>';
            }
        }
    }
}

export default hackChrome;