Desenvolvimento da Interface de Navegação e Pesquisa em um Leitor de eBooks Vue.js

A construção de um painel de navegação robusto e uma funcionalidade de pesquisa eficiente são cruciais para a experiência do usuário em um leitor de eBooks. Este artigo detalha a implementação dessas características em uma aplicação Vue.js, abordando desde a estrutura básica de componentes até técnicas avançadas de manipulação de dados e otimização da interface.

Estrutura e Organização de Componentes

O painel de navegação é concebido como uma grande sobreposição (overlay), com uma área dedicada ao conteúdo do diretório à esquerda e um fundo que se esmaece à direita. Internamente, o painel é dividido em duas abas principais: "Sumário" (Table of Contents) e "Marcadores" (Bookmarks), complementadas por uma animação de entrada suave.

Essa arquitetura é modularizada em quatro componentes principais:

  • Um componente-pai para gerenciar o painel completo.
  • Três subcomponentes para as seções específicas: Sumário, Marcadores e a Animação de Entrada.

Efeito de Transição do Painel

Para criar uma transição harmoniosa onde o painel desliza da esquerda para a direita enquanto o fundo aparece gradualmente, a animação é dividida em duas camadas:

  • A camada externa (o container principal) controla a opacidade geral, fazendo com que o painel e o fundo apareçam de forma suave.
  • A camada interna (o conteúdo real do painel) gerencia o movimento de deslizamento para a direita.

Essa abordagem em camadas garante uma animação mais fluida e visualmente agradável.

Alternância de Abas e Campo de Busca

A alternância entre as abas "Sumário" e "Marcadores" é implementada utilizando a funcionalidade de componentes dinâmicos do Vue.js. Um componente <component :is="currentTabComponent"></component> é utilizado, onde currentTabComponent é uma propriedade computada que resolve para o componente apropriado (e.g., TableOfContents ou Bookmarks) com base na aba selecionada. A estilização da aba ativa é controlada dinamicamente através da vinculação de classes.

<!-- Exemplo de uso de componente dinâmico -->
<component :is="activeTab === 'toc' ? 'TableOfContents' : 'Bookmarks'"></component>

Para o campo de busca, a visibilidade de um botão "Cancelar" é controlada por uma variável reativa (e.g., searchActive). O botão aparece quando o campo de entrada está em foco e permite limpar a pesquisa ou ocultar o campo. O layout do campo de busca é otimizado com Flexbox para uma organização responsiva.

Disposição das Informações do Livro

A parte superior do painel de navegação exibe informações essenciais do livro, como a capa, metadados (título, autor) e o progresso da leitura. Este layout horizontal é construído com Flexbox e dividido em três seções: imagem da capa (esquerda), detalhes do livro (centro) e progresso da leitura (direita). A internacionalização (i18n) é aplicada a todos os textos para suportar múltiplos idiomas.

Uma técnica importante utilizada para eliminar o espaçamento indesejado que pode ocorrer entre elementos div que contêm texto é definir font-size: 0 no container pai, e depois redefinir o font-size nos elementos filhos. Isso ajuda a controlar precisamente o layout.

Para títulos ou nomes de autores extensos, a técnica de truncamento com reticências (ellipsis) é aplicada. É crucial que o elemento que utiliza ellipsis tenha uma altura definida, caso contrário, o texto pode não ser truncado corretamente e o contêiner pode se expandir além do desejado.

Desenvolvimento do Sumário (Table of Contents)

Normailzação da Estrutura de Dados

O sumário de um eBook geralmente possui uma estrutura hierárquica (em árvore). Para facilitar a exibição linear, essa estrutura é "achatada" em um array unidimensional. Este processo envolve a utilização de recursão e operadores de espalhamento (spread operators) do ES6 para concatenar os itens e seus subitens.

/**
 * Achata uma estrutura de array aninhada em um array unidimensional.
 * @param {Array} entries - O array de entradas do sumário, que pode conter subitens.
 * @returns {Array} Um array unidimensional de todas as entradas.
 */
function flattenEntries(entries) {
  return entries.reduce((acc, entry) => {
    return acc.concat(entry, entry.children ? flattenEntries(entry.children) : []);
  }, []);
}

Após o achatamento, é necessário determinar o nível de aninhamento (profundidade) de cada item. Isso é feito por meio de outra função recursiva que percorre a hierarquia do pai até o item raiz, atribuindo um nível a cada entrada.

const flattenedNav = flattenEntries(bookNavigation.sections); // Assumindo 'bookNavigation.sections' é a estrutura original
function assignDepth(entry, currentDepth = 0) {
    if (entry.parent) {
        const parentEntry = flattenedNav.find(item => item.id === entry.parent);
        if (parentEntry) {
            currentDepth = assignDepth(parentEntry, currentDepth) + 1;
        }
    }
    entry.level = currentDepth;
    return currentDepth;
}
flattenedNav.forEach(entry => assignDepth(entry));

Componente de Rolagem Personalizado

Um componente de rolagem reutilizável (e.g., <ScrollContainer>) é desenvolvido para gerenciar as áreas rolantes da aplicação. Este componente aceita propriedades como topOffset (altura do topo), bottomOffset (altura da base) e initialPosition (posição de rolagem inicial), permitindo que ele calcule sua própria altura e configure um slot para o conteúdo do usuário.

A função refreshScroll é invocada sempre que há uma mudança de layout ou conteúdo para recalcular e ajustar a altura da área rolante. Uma função responsivePx é utilizada para escalar valores de pixel com base na densidade da tela, garantindo consistência visual em diferentes dispsoitivos.

refreshScroll() {
    if (this.$refs.scrollWrapper) {
        // Assume responsivePx converte um valor px padrão para o px real na tela
        this.$refs.scrollWrapper.style.height = `${window.innerHeight - responsivePx(this.topOffset) - responsivePx(this.bottomOffset)}px`;
        this.$refs.scrollWrapper.addEventListener('scroll', this.handleScroll, { passive: true }); // Otimização de performance
    }
}

Para evitar o bloqueio da rolagem padrão em dispositivos móveis, o modificador .passive é utilizado em ouvintes de eventos de rolagem (e.g., @scroll.passive). A configuração inicial da rolagem e o ajuste da altura são realizados no hook mounted, utilizando this.$nextTick para garantir que o DOM já esteja atualizado.

Estilos CSS específicos são aplicados para personalizar a barra de rolagem:

  • -webkit-overflow-scrolling: touch;: Habilita a rolagem suave com inércia em dispositivos iOS.
  • Oculta a barra de rolagem padrão do navegador com &::-webkit-scrollbar { display: none; }.

Renderização do Sumário

O sumário é renderizado usando v-for sobre o array achatado flattenedNav. Para lidar com textos longos que excedem a largura disponível, o Flexbox é usado no contêiner pai, e a propriedade flex: 1 é aplicada ao elemento de texto dentro da linha, permitindo que ele preencha o espaço restante e, se necessário, use ellipsis.

A indentação de cada item é aplicada dinamicamente usando margin-left, baseada na propriedade level (profundidade) calculada anteriormente. Cada nível adiciona um valor específico de margem (e.g., 15px).

getItemStyle(entry) {
    return {
        'margin-left': `${pixelsToRem(entry.level * 15)}rem` // Converte px para rem
    };
}

O capítulo atualmente visualizado é destacado aplicando uma classe CSS (e.g., .selected-chapter) se o índice do item no sumário corresponder ao capítulo ativo no estado da aplicação. Para garantir que caracteres como 'y' ou 'g' sejam totalmente visíveis, o line-height é definido para ser ligeiramente maior que o font-size.

Ao clicar em um item do sumário, o leitor navega para a seção correspondente usando o método display(href) da biblioteca epubjs. Uma função de callback é integrada para ocultar a barra de título e o menu após a navegação, mantendo a função display focada apenas na navegação do conteúdo.

Aprimoramentos e Correções

  • A função que retorna o nome da seção ativa (e.g., getSectionTitle()) foi aprimorada para lidar corretamente com a estrutura de sumário achatada, garantindo que o nome da seção correta seja exibido, mesmo para subníveis.
  • Para prevenir erros durante a rápida alternância de capítulos, são adicionadas verificações para assegurar que o livro e a sua estrutura de navegação estejam completamente carregados e parseados antes de tentar acessar a localização atual ou mudar de capítulo.

Funcionalidade de Pesquisa de Texto Completo

Princípio da Busca

A biblioteca epubjs oferece o método doSearch, que percorre os itens da 'spine' (esqueleto) do livro para encontrar ocorrências de uma palavra-chave. Ele retorna um array de arrays, onde cada sub-array contém os resultados encontrados em uma seção específica do livro. Para facilitar o processamento, este resultado bidimensional é "achatado" em um único array unidimensional usando Array.prototype.concat.apply([], results).

async performFullTextSearch(query) {
    // currentBook é a instância do livro epubjs
    const searchPromises = this.currentBook.spine.items.map(async (item) => {
        await item.load(this.currentBook.load.bind(this.currentBook));
        const results = await item.find(query);
        item.unload(); // Libera recursos
        return results;
    });

    const allResults = await Promise.all(searchPromises);
    // Achata o array de arrays de resultados em um único array
    return [].concat(...allResults);
}

Interface e Visualização dos Resultados da Busca

Os resultados da busca são exibidos em um componente de rolagem dedicado, semelhante ao usado para o sumário. A visibilidade deste painel é controlada por uma variável que indica se há resultados de busca disponíveis (e.g., hasSearchResults).

A entrada de texto para a pesquisa utiliza v-model para vinculação bidirecional e um ouvinte de evento @keyup.enter.exact para acionar a função de pesquisa apenas quando a tecla Enter é pressionada sozinha. Os termos de pesquisa encontrados nos resultados são destacados. Isso é feito substituindo a palavra-chave original no trecho do texto por uma tag <span> com uma classe CSS específica (e.g., .search-highlight).

É fundamental usar a diretiva v-html (e.g., <p v-html="item.excerpt"></p>) para renderizar os trechos, pois ela instrui o Vue.js a interpretar a string como HTML, garantindo que as tags <span> sejam exibidas corretamente e não como texto literal.

async handleSearch() {
    if (this.searchTerm && this.searchTerm.length > 0) {
        const results = await this.performFullTextSearch(this.searchTerm);
        this.searchResults = results.map(result => {
            // Substitui o termo de busca no trecho por uma versão destacada
            result.excerpt = result.excerpt.replace(
                new RegExp(this.searchTerm, 'gi'), // 'gi' para busca global e insensível a maiúsculas/minúsculas
                match => `<span class="search-highlight">${match}</span>`
            );
            return result;
        });
    }
}

Além de destacar os resultados na lista, é possível realçar a palavra-chave diretamente no conteúdo do livro. Isso é conseguido modificando a função de exibição de conteúdo para aceitar um parâmetro highlight. Se highlight for true, o método currentBook.rendition.annotations.highlight(target) é invocado para desenhar um realce visual na posição especificada do texto.

navigateToContent(cfiString, shouldHighlight = false) {
    this.display(cfiString, () => {
        this.hideTitleAndMenu(); // Callback para esconder a interface após navegação
    });
    if (shouldHighlight) {
        // Assume 'currentBook.rendition' é a instância de rendição do epubjs
        this.currentBook.rendition.annotations.highlight(cfiString, {}, (e) => console.log('Highlight created', e));
    }
}

Animações de Carregamento Personalizadas

Em vez de usar imagens GIF estáticas, uma animação de carregamento é criada inteiramente com CSS e elementos HTML. Um componente <EbookLoading> separado é exibido enquanto o livro ainda está sendo carregado (ou seja, enquanto bookAvailable é false).

A animação consiste em um contêiner com seis linhas verticais (três de cada lado), estilizadas com Flexbox. Cada linha é composta por um elemento line e um mask. A animação é alcançada através da manipulação gradual do comprimento e posição desses elementos line e mask. A sequência é cuidadosamente orquestrada para que a próxima linha comece sua animação quando a linha anterior atinge um determinado ponto, criando um efeito de onda contínuo.

Tags: Vue.js ePub.js Front-end Development UI/UX Component Design

Publicado em 7-11 08:16