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.