diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 109ec56..eb846f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,20 @@ jobs: - run: python -m pip install -r requirements-test.txt - run: npm run build - run: npm run check + - run: npx playwright install chromium + - run: python scripts/fixtures.py + - run: npm run test:ui + # The native suite needs the WebView2 runtime, which a runner image may not have. + - name: WebView2 runtime + shell: pwsh + run: | + $id = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' + $keys = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\$id", "HKCU:\Software\Microsoft\EdgeUpdate\Clients\$id" + $version = $keys | Where-Object { Test-Path $_ } | ForEach-Object { (Get-ItemProperty $_).pv } | Select-Object -First 1 + if ($version) { "WebView2 Runtime $version"; exit 0 } + 'WebView2 Runtime not found; installing it.' + Invoke-WebRequest 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile "$env:RUNNER_TEMP\webview2.exe" + Start-Process "$env:RUNNER_TEMP\webview2.exe" -ArgumentList '/silent', '/install' -Wait - run: npm run test:native - run: npm run package:source - name: Test evidence from the isolated CI library @@ -49,3 +63,9 @@ jobs: test-results/brightness/results.json test-results/kindle/results.json test-results/kindle/restart.json + test-results/joined-pages/results.json + test-results/chapters/results.json + test-results/reader-chrome/results.json + test-results/spread/results.json + test-results/sidebar/results.json + test-results/reader-chrome/*.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 8412903..4bf635c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,99 @@ # Histórico +## Não publicado + +- Interface mais enxuta: sai o texto que não informa nem permite uma ação, e a biblioteca fica mais densa. Somem "PDF READER" e o bloco "Só no seu computador" da barra lateral, o eyebrow e o subtítulo da biblioteca (o título é o nome do filtro, e só Removidos tem uma linha: "As cópias ficam guardadas até você excluí-las."), o rótulo repetido da linha da coleção (ela mostra só a contagem, "6 documentos · 31 KB", ou "N resultados" numa busca, com "Ordenar por" e grade/lista à direita), o rodapé da biblioteca (a pasta está nas Preferências), os eyebrows dos diálogos e dos estados vazios (que ficam com uma frase objetiva, como "Nenhum documento ainda"), a introdução do guia (o botão passa a "Fechar"), a frase da amostra das Preferências, o rótulo do rodapé do leitor ("Cópia local", "Kindle · ← → ou Espaço") e o título "Suas ideias, por perto" da aba Notas (agora "Nenhuma nota ainda"). O cabeçalho do leitor só fala de salvamento quando há o que dizer: "Salvando…" se a gravação passar de 1 s, ou o erro enquanto durar. A faixa "Continuar" vira uma linha de cerca de 80 px (capa em miniatura, título, "Página X de Y · Z% · ≈ tempo para terminar", barra fina e o botão "Continuar", sem a arte de órbitas; clicar em qualquer parte dela abre o documento), o título da biblioteca fica menor e leva a meta do dia na mesma linha, e a busca ocupa o topo. Ao abrir um diálogo, os avisos informativos que estavam na tela saem (os erros ficam), e os que chegam com ele aberto aparecem no topo do diálogo, não sobre os botões. +- Preferências em seções. O diálogo fica mais largo (720 px) e ganha abas na lateral (`role="tablist"` vertical, setas, Home e End, com o foco inicial na aba ativa; até 800 px as abas vão para o topo): Aparência (tema, cor das páginas, brilho, tela quente e a amostra), Leitura (forma de leitura, páginas na tela, "Continuar de onde parei" e o bloco "No modo Kindle", com "Unir conteúdo das páginas" e "Capítulos em nova tela", desabilitado e com "Disponível no modo Kindle" na rolagem), Teclado ("Atalhos de uma tecla", a lista das teclas e "Ver todos os atalhos", que abre o guia) e Biblioteca (a pasta, com "Abrir pasta"). O diálogo tem altura fixa, só a seção rola e o rodapé fica só com "Pronto"; as mudanças continuam valendo na hora. Ao reabrir, volta na última seção usada (só na sessão), e `showPreferences(prefs, ações, 'aparencia')` abre direto numa seção (`aparencia`, `leitura`, `teclado` ou `biblioteca`). Os ids dos controles não mudam; os testes passam a abrir a aba certa antes. +- Barras e menu do leitor reorganizados. O cabeçalho fica com Voltar, o título, **Concluir leitura**, o favorito e o menu ⋯: saem dele o botão Kindle (vai para a barra de ferramentas, junto das duas páginas), "Ler do início" e a engrenagem de Preferências (ambos vão para o menu ⋯). A barra de ferramentas fica com a barra lateral, a busca e a página à esquerda e, à direita, o zoom, o Kindle e as duas páginas, a marca da página, **Aparência** e a tela cheia. O botão **Aparência** substitui o de papel, o de Brilho e o de Tela quente e abre um painel (`role="dialog"`) com os três papéis, o brilho (com "Restaurar"), a tela quente (com "Desativar") e "Mais preferências…", que abre as Preferências na seção Aparência; um ponto no ícone avisa que o brilho está abaixo de 100% ou a tela quente acima de 0%, e o nome do botão diz os valores. A tecla D continua percorrendo os papéis e anuncia o papel a leitores de tela. "Girar página" sai da barra e vai para o menu ⋯, que no leitor fica na ordem Ler do início, Girar página (R), Meta de leitura…, Renomear na biblioteca…, Etiquetas…, Exportar uma cópia…, Exportar notas…, Preferências…, Guia e atalhos… (F1) e Remover da biblioteca, sem "Marcar como concluído" (o cabeçalho já tem Concluir leitura); a tecla R continua girando, com "Desfazer". Na biblioteca o menu do cartão não muda. Os ids `#reader-settings`, `#paper-toggle`, `#reading-brightness`, `#reading-warm`, `#rotate` e `#restart` deixam de existir (o painel usa `#reader-appearance`, `#quick-brightness`, `#quick-warm` e os botões `#quick-brightness-reset` e `#quick-warm-reset`); os testes abrem as Preferências, "Ler do início" e "Girar página" pelo menu ⋯ e os ajustes rápidos pelo painel. +- Meta geral conta toda a leitura do dia. Ela soma todos os documentos com leitura no dia, também os que têm meta própria ou estavam em "Sem meta", os de Removidos e os excluídos definitivamente (antes só somava os que usavam a configuração geral: 1 capítulo lido num documento com meta própria de 2 capítulos aparecia como 0 de 2 na meta geral). Cada documento tem duas opções, "Sem meta própria" (só entra na meta geral) e "Meta própria" (um alvo do documento, que também entra na geral); `inherit` e `none` de catálogos antigos valem "sem meta própria", nada é perdido e o Rust aceita os três valores. O rodapé do leitor mostra a meta do documento, se ele tem uma, ou a geral; a biblioteca mostra sempre a geral. O Histórico recalcula também os dias passados (a meta geral de cada dia contra toda a leitura do dia; em um documento, a meta própria do dia ou "Sem meta própria") e abandona a participação por documento da antiga "Meta geral compartilhada". +- Metas de leitura em duas partes. O diálogo deixa de ter o seletor "Configurar para" (com todos os documentos), o bloco "Hoje", o seletor de meta e o "Salvar meta" único. Ele traz a **Meta geral** (Sem meta geral ou Com meta geral, quantidade e unidade, "Hoje: 1 de 2 capítulos" com a barra, e o seu "Salvar") e, só quando é aberto a partir de um documento (menu do cartão, menu ⋯ ou rodapé do leitor), **Este documento** ("Sem meta própria" ou "Meta própria", quantidade e unidade, o progresso do documento com barra e o seu "Salvar"). Para configurar outro documento o caminho é o menu dele. Cada "Salvar" deixa o diálogo aberto e atualiza só o progresso, com um aviso no topo. A regra vem numa frase ("A meta geral conta tudo o que você ler no dia. A meta própria é um alvo extra deste documento."), "Ver histórico" é um link junto ao título, e "Ajustar leitura de hoje" e "Como a contagem funciona" ficam recolhidos no fim; o ajuste vale para o documento aberto e, pela biblioteca, deixa escolher o documento num seletor que só existe ali. O foco inicial vai para a primeira escolha, a validação da quantidade, o redesenho que mantém o foco (`modal(html, true)`) e a proteção da virada do dia no ajuste continuam. Os controles `#goal-mode` e `#goal-scope` deixam de existir: a meta geral usa os botões `#goal-mode-none` e `#goal-mode-custom` (com `#goal-target`, `#goal-unit` e `#goal-save`), o documento usa `#goal-own-inherit`, `#goal-own-custom`, `#goal-own-target`, `#goal-own-unit` e `#goal-own-save`, e os progressos são `#goal-current` e `#goal-own-current`; `#goal-log-book` só aparece sem documento. Os testes abrem o diálogo pelo menu do documento ou pelo rodapé do leitor. +- Polimento de avisos, foco e rótulos (lote I da auditoria de UX). "Trecho realçado." e "Nota salva." deixam de abrir uma caixa sobre o texto quando o resultado já aparece (o realce na página, o cartão na aba Notas) e passam a ser anunciados a leitores de tela por uma região de status sem caixa (`#quiet-status`, `announce`); a nota de um realce editada com a aba Notas fora de vista mantém o aviso. A barra de realce passa para cima da primeira linha do trecho quando há espaço na área de leitura, para não cobrir o texto que vem depois. "Nenhuma posição anterior." não cita mais Ctrl + W. O favorito tem o mesmo padrão na biblioteca, na barra de seleção e no leitor: nome fixo ("Favoritar {título}", "Marcar como concluído") com `aria-pressed`, e a dica diz a ação de agora. A ordem da biblioteca ganha o rótulo "Ordenar por" e a borda dos campos (`--field-line`); Removidos mostra a contagem de documentos. O campo de busca em foco ganha contorno de 2 px no acento, e o anel de foco das miniaturas deixa de ser cortado pela lista. O sumário é uma parada de Tab só (roving tabindex, com um aviso das setas para leitores de tela), e um item sem destino no documento fica com aparência de indisponível (`aria-disabled`, sem hover) e diz "Este item não tem destino neste documento." ao ser acionado. O papel sépia da página passa a ter o tom que a amostra das preferências mostra (`--paper-sepia-filter` e `--paper-sepia-page`, o mesmo nas miniaturas). +- Teclado: selecionar trechos e andar pela biblioteca e pelo calendário. Com o foco nas páginas, Shift + ← e → crescem a seleção por palavra e Shift + ↑ e ↓ por linha (na rolagem e no Kindle, onde as setas sem Shift continuam virando telas e Shift + Espaço continua voltando); sem seleção, ela começa no primeiro texto à vista (ou onde se clicou), fica restrita ao texto das páginas, e a tela acompanha o fim dela (o Kindle vira a tela). A barra de realce, o H e o N agem sobre essa seleção, Esc a limpa, e H ou N sem trecho avisam "Selecione um trecho: Shift + setas ou o mouse."; um leitor de tela é avisado quando há um trecho selecionado. Na biblioteca, os cartões são uma parada de Tab só (roving tabindex): as setas movem o foco (na grade, nas quatro direções, pela posição real dos cartões; na lista, ↑ e ↓), Home e End vão ao primeiro e ao último, Enter abre, Espaço (e Shift + Espaço, para o intervalo) marca o cartão, e a caixa de seleção sai da ordem de Tab; os botões de favorito e de opções ficam na ordem de Tab só no cartão ativo. No calendário do Histórico, os dias são uma parada de Tab só (o dia escolhido): ← → andam um dia, ↑ ↓ uma semana, Home e End vão ao começo e ao fim da semana, Page Up e Page Down trocam de mês, Enter e Espaço escolhem o dia, e um dia futuro ou um mês sem registro é recusado com um aviso. O foco dos botões do cartão passa a aparecer por dentro dele, e o guia traz os atalhos novos. +- Marcas, notas, guia e teclas do leitor. Cada marca da aba Marcas ganha "Remover marca" (com "Desfazer", e o foco segue para a marca vizinha ou para a aba); a citação do cartão de nota leva ao trecho como "Página N", e o sumário (12 px) e o texto das notas (12 e 13 px) ficam maiores. O guia abre com F1 (e com "?" quando os atalhos de uma tecla estão ligados) e está em "Mais opções" no leitor; a cor que o H usa leva "(H)" na dica da barra de realce. K, L, D e R não repetem ao segurar a tecla, e girar as páginas avisa com "Desfazer". Na biblioteca, Esc na busca com texto limpa o campo e o filtro. "Tentar novamente", no erro ao abrir a biblioteca, passa a funcionar (a política da página bloqueava o `onclick` do HTML), e fechar o diálogo com Esc também tenta de novo. +- Avisos que chegam com um diálogo aberto, fila de arquivos e nomes acessíveis. Enquanto um diálogo está aberto, o contêiner dos avisos (`#toast`) vive dentro dele (`placeToasts`), pois a camada superior do `showModal()` cobre qualquer `z-index` e deixa o resto da página inerte; assim "Escreva uma nota antes de salvar.", os erros e o "Desfazer" aparecem e respondem, e o contêiner volta à página ao fechar. Ao chegar o quarto aviso sai o informativo mais antigo sem ação (`toastToDrop`); o aviso com "Desfazer" só sai se não houver outro, e o erro fica até ser fechado. Arquivos e pastas soltos durante a cópia entram numa fila ("Na fila: 3 itens.") e são importados quando ela termina; Ctrl + O durante a cópia diz "Aguarde a cópia dos PDFs terminar.". Nova preferência "Atalhos de uma tecla" (ligada por padrão, `singleKeys`): desligada, K, L, S, D, R, H e N ficam inertes, e os atalhos com Ctrl ou Alt, as setas, Espaço, Page Up/Down, Home/End e F11 continuam (WCAG 2.1.4); o guia a cita. O diálogo é nomeado pelo primeiro título (`aria-labelledby`), e o botão do cartão da biblioteca, "Ler {título}", é descrito pelo autor e pelo estado (progresso, "Concluído" ou "Ainda não lido"). Salvar, editar, mudar a cor ou excluir uma nota devolve o foco ao cartão dela na aba Notas, ao vizinho se ela foi excluída, ou à busca. +- Controles e acessibilidade. A grade/lista anuncia o estado (`role="radio"` com setas); o botão de tema mostra o destino (sol no escuro) e o papel tem ícone próprio; Removidos deixa de apagar o cartão inteiro (a capa é só texto ali); "Remover da biblioteca" ganha ícone próprio e a cor de perigo no menu; Meta, Histórico e "Ler do início" ganham glifos próprios; a caixa de seleção do cartão tem 24 px e fica visível, esmaecida; os botões do rodapé do leitor e os controles deslizantes têm alvo de pelo menos 24 px. O texto secundário (`--muted`) passa de 4,5:1 nos dois temas e as bordas de campos e o trilho do interruptor usam o novo `--field-line` (3:1). +- Avisos, estados e consistência visual. Os avisos da biblioteca saem ao abrir um documento (os de erro ficam) e, no leitor, vão para o canto, acima do rodapé. Cada falha diz qual ação falhou ("Não foi possível favoritar…") e um erro já mostrado não é repetido; salvar a posição conta a falha uma vez, tenta novamente com intervalo crescente (2 s a 30 s) e o cabeçalho diz "Salvando…" (só se a gravação passar de 1 s) ou o erro, e nada mais. O carregamento do PDF é anunciado (`role="status"`) e o foco vai para "Voltar à biblioteca". A dica de concluir só vem em documento com rolagem. "Exportar uma cópia" e "Exportar notas" oferecem "Abrir pasta" (novo comando `reveal_file`: o Explorer abre com o arquivo selecionado). A alça da barra lateral tem um traço visível em repouso; os ícones têm traço de espessura fixa em pixels (`vector-effect`); "Marcas" destaca a página atual com peso e barra, como o sumário; as linhas de interruptor das preferências ganham espaço; o botão de remover etiqueta passa a 24 px. Novos tokens: tipografia (`--fs-*`), movimento (`--motion-*`), opacidade de desabilitado, `--missed` (com variante escura), `--cover-h` e `--reader-inset`. +- Diálogos, teclado e foco. Todo diálogo abre com o foco no campo (ou na ação principal) e não mais no X: senha, Renomear, Adicionar nota, Metas, Histórico e Preferências, e a janela volta ao topo ao abrir; a senha incorreta liga o aviso ao campo (`aria-describedby`, `aria-invalid`). Renomear salva com Enter, desativa "Salvar título" com o campo vazio e diz por quê. Metas e Histórico deixam de perder o foco: o diálogo que se redesenha devolve o foco ao controle (pelo `id`), guarda o painel aberto e a rolagem (`modal(html, true)`), e "Salvar leitura de hoje" atualiza só o total de hoje; o escopo e o mês passam a ser operáveis por setas. O Histórico aberto a partir de Metas ganha "Voltar às metas" e avisa o intervalo de meses válido em vez de reverter em silêncio; o dia sem registro diz isso uma vez só. Esc numa nota com texto novo pergunta antes de descartar, dentro do diálogo. Favoritar, concluir, renomear e remover pelo menu do cartão mantêm o foco (`keepLibraryView`, `settleFocus`). No modo rolagem, ← e → rolam para os lados quando o PDF é mais largo que a janela e só trocam de página quando não há o que rolar; o Kindle não muda. O visualizador ganha anel de foco (`:focus-visible`) e `role="region"`. Preferências: o título é o do menu ("Preferências"), "Tema do aplicativo" tem rótulo ligado e as cores das páginas anunciam o estado (`aria-pressed`, grupo nomeado); o cabeçalho e "Pronto" ficam à vista enquanto o miolo rola. O guia vira "Guia e atalhos": passos curtos em `h3`, a tabela de atalhos logo depois e os detalhes (Kindle, saltos, realces, etiquetas, barra lateral) em seções próprias, com as linhas de ← →, Ctrl + K, F11 e Esc corrigidas. Nenhum texto informativo fica abaixo de 11 px (`--fs-micro` 10 px só para teclas) e o apoio dos diálogos passa a 12 px. +- Textos, rótulos e dicas com um termo para cada coisa. "Documento" é o item da biblioteca (sai "livro" da interface, do guia e do README) e "PDF" é só o arquivo; "Todos os PDFs" vira "Todos os documentos". O escopo geral de Metas e Histórico passa a ser "Biblioteca inteira" (antes "Aplicação inteira" e "Todos os livros"). "Concluir leitura" e "Tela cheia (F11)" mantêm o nome e dizem o estado só em `aria-pressed`. A aba Notas conta "N notas · N realces", e criar uma nota é sempre "Adicionar nota" (barra de seleção, painel, menu e diálogo; no cartão, "Editar nota"). O menu do leitor não oferece "Abrir documento" para o documento que já está aberto. Excluir é sempre "excluir" (antes também "apagar"), e repetir é sempre "novamente". Os itens de menu que abrem diálogo ou seletor ("Renomear na biblioteca…", "Meta de leitura…", "Exportar uma cópia…", "Exportar notas…") levam reticências. Os avisos de importação terminam em ponto, e as teclas da biblioteca seguem o formato "Ctrl + O". A mensagem vazia de Marcas cita "Marcar esta página", o guia chama o pino de nota de "ícone de nota", e o campo de busca do PDF tem o mesmo nome no rótulo e no texto de apoio. +- Biblioteca e barras do leitor, sem perder o contexto. Na grade, o título do cartão tem até duas linhas (a lista usa a largura toda), e a moldura das capas segue o tema escuro (`--cover-0` a `--cover-4`, borda `--line`). O topo da biblioteca (busca e tema) fica fixo ao rolar. Em Removidos some o total da biblioteca, que repetia o painel da lixeira; a meta diária, agora com uma barra fina do dia, só aparece em Todos os documentos e Em leitura, fora da busca. A busca sem resultado ganha "Limpar busca" no próprio estado vazio, e um aviso explica quando a busca ou a troca de filtro tira documentos da seleção. As linhas da lista ficam mais baixas (cerca de 90 px, antes 113 px). No leitor, a tecla D percorre claro, sépia e escuro; com duas páginas o rodapé diz "Páginas 3–4 de 6"; na tela cheia uma linha de 3 px na borda inferior mostra o progresso, uma dica única ensina a revelar as barras (também no guia, com Tab) e as teclas do Kindle ficam no tooltip do botão e no guia; e o rodapé começa pelo progresso. +- O Fólio abre PDFs pelo Explorer e lembra a janela. O instalador registra `.pdf` (`bundle.fileAssociations`, classe `Folio.PDF`, visualizador) e oferece o Fólio em **Abrir com** sem mudar o programa padrão do usuário: o gancho `src-tauri/windows/installer-hooks.nsh` devolve o valor de `HKCU\Software\Classes\.pdf` que o Tauri trocaria, inclui a classe em `OpenWithProgids` e desfaz isso ao desinstalar. Os arquivos da linha de comando (da primeira instância ou entregues por uma segunda, com os caminhos relativos resolvidos pela pasta dela) ficam num estado nativo; o novo comando `take_pending_files` os devolve uma vez e o evento `open-files` avisa que há mais. O frontend os importa por `import_pdfs`, abre o primeiro documento (mesmo com outro aberto, que guarda a posição), mostra o resumo quando são vários, e espera uma importação em andamento. `tauri-plugin-window-state` guarda tamanho, posição e se estava maximizada (nunca a tela cheia); uma janela que não cabe mais nos monitores volta menor, ao centro, e a primeira execução continua centralizada. O estado fica em `%APPDATA%\com.lume.pdf\.window-state.json`, e a compilação de desenvolvimento usa outro arquivo. +- Notas e realces no próprio texto. Ao selecionar um trecho no PDF, uma barra flutuante junto ao fim da seleção oferece Realçar (amarelo, verde, azul ou rosa), Adicionar nota e Copiar; ela some ao desfazer a seleção, ao rolar, ao clicar fora e com Esc, e nunca cobre o trecho nem sai da janela. As teclas H (realçar na última cor) e N (adicionar nota) agem sobre a seleção. O realce é uma nota com `rects` (um retângulo `[x1, y1, x2, y2]` por linha, em unidades do PDF, independentes de zoom e rotação) e `color`, e o texto pode ficar vazio; uma seleção que atravessa páginas vira um realce por página (ao anotar, vale o trecho da primeira). Uma camada por página, dentro da página do PDF.js e acima do texto, desenha os realces sem atrapalhar a seleção nem os links, e os redesenha ao renderizar, no zoom, na rotação, na troca de modo, nas duas páginas lado a lado e na junção de margens do Kindle. Os papéis claro e sépia multiplicam a cor sobre a página; o escuro, que inverte as cores, usa uma luz translúcida. Notas com texto ganham um ícone de nota na margem. Clicar num realce abre o menu Editar nota, Mudar cor, Copiar texto e Excluir realce (com "Desfazer"); a aba Notas ganhou busca por texto e citação, a amostra da cor, "Realce" para os sem texto e o salto até a altura do realce, que usa o histórico de voltar. Os campos `rects` e `color` são opcionais (`#[serde(default)]`), validados no Rust (números finitos, no máximo 500 retângulos por nota, uma das quatro cores) e ausentes das notas antigas, que continuam abrindo. +- Exportar notas. "Exportar notas" na aba Notas e no menu do documento abre o diálogo de salvar com o filtro `.md` e chama o novo comando nativo `export_notes(id, destination, offsetMinutes?)`, que monta o Markdown no Rust a partir do catálogo (título, autor, notas por página em ordem de leitura, com a citação em blockquote, o texto, a cor do realce e a data no fuso de quem lê), exige a extensão `.md` e grava de forma atômica (arquivo temporário na mesma pasta e troca). + +- Biblioteca que escala. Etiquetas (até 20 por documento, de até 40 caracteres, sem repetir ignorando maiúsculas e acentos) pelo menu "Etiquetas…", com sugestões das já usadas, uma seção Etiquetas na barra lateral com contagem e filtro, e as primeiras etiquetas nos cartões. A busca da biblioteca passa a incluir etiquetas e o texto das notas (texto e citação), e o cartão diz "Encontrado nas notas" quando só elas respondem. +- Seleção múltipla (Ctrl + clique, Shift + clique, a caixa do cartão, Ctrl + A e Esc) com uma barra fixa de ações em lote: favoritar, concluir, adicionar etiqueta, remover da biblioteca (com "Desfazer" para todos) e, em Removidos, restaurar e excluir definitivamente. Os comandos nativos `update_books`, `remove_books`, `restore_books` e `purge_books` fazem uma única gravação atômica do catálogo: se um item for recusado, nada muda. +- Esvaziar Removidos. "Excluir definitivamente" no menu de cada item e "Esvaziar Removidos" no topo da aba pedem confirmação (com o número de documentos e o espaço liberado, o foco inicial em Cancelar) e depois gravam o catálogo e excluem `pdfs/.pdf` e `covers/.png`. Um arquivo que não pôde ser excluído não desfaz o catálogo, e o aviso diz o caso. O histórico de leitura é mantido: o catálogo guarda para cada documento excluído um registro `archived` (id, título, autor, `readingDays` e `goalHistory`) que entra no Histórico e não aparece na biblioteca nem em Removidos. A deduplicação na importação ignora esses registros, então reimportar o PDF cria um documento novo. O campo `tags` e a lista `archived` são novos no catálogo; catálogos antigos continuam abrindo. + +- Progresso de leitura calculado em um só lugar. O rodapé do leitor (rolagem e Kindle) e a biblioteca (cartão Continuar leitura, grade e lista) mostram o mesmo percentual para a mesma página. +- A primeira página é 0%; a última página, o fim do documento no leitor e os documentos concluídos são 100%. O valor não muda com o tamanho da janela, o zoom ou a junção de páginas do Kindle. +- Tempo restante estimado no rodapé do leitor, como "≈ 12 min no capítulo · 3 h 10 min no documento", e no cartão Continuar leitura. O ritmo vem da leitura ativa, com os mesmos critérios das metas, e fica nas preferências por documento. Nada aparece antes de cinco páginas medidas, e sem capítulos reconhecidos só o tempo do documento é mostrado. + +- Duas páginas lado a lado na rolagem contínua, para monitores largos: "Duas páginas" ou "Duas páginas, capa separada" (a página 1 fica sozinha). A escolha está nas preferências, no botão ao lado do zoom e na tecla L, vale para todos os documentos e fica salva. +- Com duas páginas, os botões e as setas avançam por par, "Largura da página" e "Página inteira" consideram o par e a posição de leitura é mantida ao trocar de disposição. O modo Kindle usa uma coluna e deixa a escolha de lado; ela volta ao sair dele. +- As metas contam as duas páginas em vista de um par depois dos mesmos oito segundos, e o capítulo que começa na segunda página do par também é concluído. O ritmo de leitura divide o tempo de um par entre as duas páginas. +- Notas feitas a partir de um trecho selecionado ficam na página do trecho, mesmo quando é a outra página do par. + +- Barra lateral do leitor mais informativa. O sumário passa a marcar a seção que você está lendo e a rolar até ela sem tirar o foco. Os níveis se recolhem: por padrão aparecem o primeiro nível e o caminho até a seção atual, e o teclado funciona (setas, Enter e Espaço). +- Nova aba Páginas, com miniaturas desenhadas só perto da área visível e liberadas ao sair dela, no máximo 16 ao mesmo tempo. Ela marca a página atual, ou as duas páginas de um par lado a lado, e leva à página com clique, Enter ou Espaço. As miniaturas seguem a rotação e o papel escolhidos. +- A barra lembra se estava aberta, a aba e a largura, em todos os documentos e depois de reiniciar. Arraste a borda dela, ou use as setas, para mudar a largura, de 220 a 480 px e no máximo 40% da janela; o valor é gravado ao soltar. A tecla S mostra e esconde a barra. +- A posição de leitura se mantém ao mudar a largura, na rolagem, em duas páginas e no Kindle. O sumário e os capítulos reconhecidos passam a compartilhar a mesma resolução dos destinos. + +- Menus que cabem na tela. O painel de opções de um documento, o botão "Mais opções" do leitor e os ajustes de Brilho e Tela quente abrem abaixo do botão quando há espaço e acima quando não há, sempre dentro da janela (margem de 8 px) e com rolagem interna como último recurso. Eles fecham ao rolar a biblioteca ou redimensionar a janela, para não ficarem longe do botão. +- Os menus de opções funcionam pelo teclado: o foco vai para o primeiro item, as setas circulam, Home e End vão às pontas, uma letra salta para o item que começa com ela, Esc fecha e devolve o foco ao botão, e Tab fecha. Os leitores de tela anunciam menu, itens e se o botão está aberto ou fechado. Esc fecha só o menu, sem sair da tela cheia nem fechar a busca. +- Clique direito num cartão ou numa linha da biblioteca abre as opções do documento no ponteiro, sem o menu do sistema. Shift + F10 ou a tecla Menu, com o foco num cartão, abrem o mesmo menu junto ao cartão. Em Removidos o menu oferece só Restaurar. + +- Avisos que não se perdem. Até três aparecem ao mesmo tempo, empilhados, e o mais antigo sai quando chega o quarto. Cada aviso tem o próprio tempo, que pausa com o mouse ou o foco, um botão Fechar (Esc fecha o aviso em foco) e a própria ação: ao remover dois PDFs seguidos, os dois mantêm o "Desfazer", e o aviso diz qual é o documento. Uma ação clicada fecha o aviso, e o movimento reduzido tira as animações. +- Erros em frases claras, em português: senha, PDF inválido ou danificado, leitura interrompida, arquivo em uso, sem permissão, sem espaço em disco e os erros da biblioteca. O texto técnico original fica atrás do botão "Detalhes". Um erro é anunciado na hora (`role="alert"`) e fica até ser fechado. "Não foi possível abrir este PDF" e a lista de arquivos não importados usam a mesma tradução. Fechar o pedido de senha não mostra erro. +- A importação mostra o andamento no botão Adicionar PDFs ("Copiando 3 de 10…", com uma barra fina), sem um aviso por arquivo. Os leitores de tela ouvem o progresso numa região discreta, e no fim há um só aviso de resumo. +- Dicas mostradas uma só vez, gravadas nas preferências (`hints`). A dica do modo Kindle e o aviso "Chegou ao fim?" aparecem na primeira vez e não voltam, nem depois de reiniciar. O aviso de catálogo recuperado do backup também deixa de se repetir a cada atualização da biblioteca. + +- Voltar para onde você estava depois de um salto. Sumário, links internos do PDF, o primeiro resultado da busca, miniaturas, marcas, notas, o campo de página, Home e End no Kindle e "Ler do início" guardam a posição de antes (página e ponto exato, que valem em qualquer zoom, rotação ou modo) e mostram sobre o PDF, sem deslocá-lo, o botão "Voltar à pág. X"; depois de voltar, ele oferece "Avançar à pág. Y". O botão some ao ser usado, em cerca de 15 s (pausa com o mouse ou o foco), com Esc em foco ou ao fechar o documento. Alt + ← volta e Alt + → avança, e os botões laterais do mouse fazem o mesmo; no máximo 50 posições por sessão do documento, sem gravar nada. Saltos para a mesma página, ou perto o bastante para estar na tela, não contam, e a busca guarda só a posição anterior ao primeiro resultado. Rolar e virar página não são saltos, e o ritmo recomeça na página de chegada. **Mudou:** voltar à biblioteca agora é Ctrl + W, porque Alt + ← passou a voltar no documento. + +- Importação que já entrega a biblioteca pronta. Uma fila em segundo plano, um PDF por vez, gera a capa e lê o número de páginas e o autor dos PDFs recém-importados e dos documentos que ainda não tinham capa ou páginas, ao abrir o Fólio. Cada cartão se atualiza sozinho, sem perder a rolagem nem o foco. A fila espera enquanto um documento está aberto e continua ao voltar à biblioteca; PDFs com senha ou inválidos são ignorados em silêncio e não são tentados de novo na mesma sessão. +- O autor aparece nos cartões da grade e da lista e entra na busca da biblioteca. O título do PDF só substitui o do documento enquanto ele ainda é o nome do arquivo (sem renomeação sua) e se for plausível: vazio, curto demais, "Untitled" ou "Sem título", "Microsoft Word - …", nome de arquivo (.doc, .pdf), só números ou pontuação ficam de fora. +- Pastas inteiras. "Adicionar pasta" (abaixo de Adicionar PDFs e no estado vazio) e o arrastar e soltar importam os PDFs da pasta e das subpastas, sem diferenciar maiúsculas. Links simbólicos e junções não são seguidos, e arquivos que não são PDF dentro da pasta são ignorados sem erro (um arquivo escolhido por nome que não seja PDF continua gerando erro). O andamento do botão conta os arquivos encontrados. +- O resumo da importação diz mais. Com um só PDF adicionado, o aviso tem a ação "Abrir". Quando só havia duplicatas, o aviso diz qual documento já existia ("“Título” já estava na biblioteca.") e "Mostrar" limpa a busca e o filtro, rola até o cartão, dá foco a ele e o destaca por um instante. O resultado de `import_pdfs` traz `existing` (id e título de cada documento que já existia), `save_cover` devolve o documento atualizado e `update_book` aceita `author`, com limites para `title`, `author` e `pages`. + +## 1.8.0 + +- Histórico de leitura com calendário mensal, filtro por livro e detalhes de cada dia. +- Totais diários e mensais de páginas e capítulos, com indicação das metas atingidas. +- Configuração de metas registrada por data, preservando os resultados anteriores após alterações. +- Aproveitamento de registros antigos sem inventar metas que não foram salvas. +- Leitura acompanhada mesmo sem meta, preservando totais antigos em formato compacto. +- Histórico disponível também para livros removidos e validado após reiniciar o aplicativo. + +## 1.7.0 + +- Ação Concluir leitura no leitor, com desfazer e indicação na biblioteca, preservando a posição. +- Metas diárias em páginas ou capítulos, globais ou próprias por livro, com opção de ficar sem meta. +- A meta geral soma somente os livros que a usam. O acompanhamento aparece na biblioteca e no rodapé do leitor. +- Contagem de páginas após tempo de leitura ativa, reconhecimento de capítulos e ajuste manual dos totais do dia. +- Registros locais dos últimos 31 dias e migração aditiva do catálogo existente. +- Testes de contagem, virada do dia, persistência, conclusão e compatibilidade com dados anteriores. + +## 1.6.0 + +- Ajustes rápidos de brilho e tela quente na barra de leitura, sincronizados com as preferências. +- Tela cheia com barras que deslizam para fora e reaparecem ao aproximar o mouse das bordas. +- Controles revelados sobre o PDF, sem alterar a posição da leitura; suporte a teclado e movimento reduzido. +- Remoção do modo foco e do atalho F8. F11 alterna a tela cheia; Esc fecha o ajuste ou a busca antes de sair. +- Testes de interface sem janela, com perfil isolado e verificação das preferências após reiniciar o navegador. + +## 1.5.0 + +- Aberturas de capítulos em uma nova tela no Kindle, com título e texto inicial juntos. +- Reconhecimento das páginas pelo sumário e por cabeçalhos explícitos de capítulo. +- Avanço preserva o final do capítulo anterior; retorno, zoom e retomada mantêm a navegação. +- Preferência independente para ativar ou desativar a separação de capítulos. + +## 1.4.0 + +- Junção automática de páginas no modo Kindle, reduzindo margens internas vazias. +- Preferência para manter as margens originais, sem modificar os PDFs. +- Recorte preserva texto, rodapés, imagens, busca, seleção e coordenadas de retomada. +- Análise sob demanda e cache limitado, sem rasterizar o documento inteiro. + ## 1.3.0 - Nome público Fólio, preservando identidade, executável, catálogo e chave de diff --git a/README.md b/README.md index f542908..269edba 100644 --- a/README.md +++ b/README.md @@ -21,23 +21,193 @@ A Windows PDF reader built with Tauri, Rust, TypeScript, and PDF.js. It keeps lo ## O que está implementado -- Importação de um ou vários PDFs, por diálogo ou arrastar e soltar. Cada arquivo - é copiado para a biblioteca; SHA-256 evita duplicatas de conteúdo. -- Busca na biblioteca, grade/lista, ordenação, favoritos, concluídos, renomeação, - exportação de cópia e restauração de documentos removidos. -- Busca textual no PDF, seleção, sumário, zoom, rotação, marcadores, notas, - foco e tela cheia. +- Importação de um ou vários PDFs, ou de uma pasta inteira com as subpastas + (**Adicionar pasta**), por diálogo ou arrastar e soltar. Cada arquivo é copiado + para a biblioteca; SHA-256 evita duplicatas de conteúdo. Links simbólicos e + junções dentro da pasta não são seguidos, e arquivos que não são PDF são ignorados. +- A importação já entrega a biblioteca pronta: uma fila em segundo plano, um PDF + por vez e parada enquanto um documento está aberto, gera a capa e lê o número de + páginas e o autor (e o título, se você ainda não renomeou o documento). Os cartões + se atualizam sozinhos. PDFs com senha ou inválidos ficam como estão. O resumo diz + qual documento já existia ("Mostrar") e abre o documento recém-adicionado ("Abrir"). +- **Abrir pelo Explorer.** O instalador coloca o Fólio na lista **Abrir com** dos arquivos `.pdf`, + sem torná-lo o programa padrão: a escolha do Windows (Configurações > Aplicativos padrão) fica + como estava, e você decide se quer mudar. Um PDF aberto assim, ou mandado ao Fólio já aberto, + entra pela importação de sempre (cópia para a biblioteca, sem duplicatas) e abre na hora, mesmo + sobre outro documento, que tem a posição salva. Com vários arquivos, o primeiro abre e o aviso resume + o resto; se uma importação estiver em andamento, eles esperam por ela. +- A janela lembra o tamanho, a posição e se estava maximizada. A tela cheia não é lembrada, e uma + janela que não cabe mais nos monitores (outro monitor, resolução menor) volta menor, ao centro. +- Busca na biblioteca (por título, autor, nome do arquivo, etiquetas ou texto das notas), grade/lista, ordenação, + favoritos, concluídos, renomeação, exportação de cópia e restauração de documentos removidos. +- **Biblioteca que escala.** **Etiquetas…** no menu do documento abre um diálogo (Enter adiciona, + cada etiqueta tem o botão de remover, e as já usadas são sugeridas). Cada documento aceita até 20 + etiquetas de até 40 caracteres; maiúsculas e acentos não distinguem duas etiquetas ("Ficção" e + "ficcao" são a mesma). A seção **Etiquetas** da barra lateral lista todas, com a contagem, e cada + uma filtra a biblioteca. Os cartões mostram as primeiras etiquetas sob o autor. A busca também + encontra etiquetas e notas (o texto e a citação); quando só as notas respondem, o cartão diz + "Encontrado nas notas". +- **Seleção múltipla.** Ctrl + clique marca ou desmarca um cartão, Shift + clique marca o intervalo + até ele, e a caixa que aparece no cartão (ao passar o mouse ou com o foco) faz o mesmo; Ctrl + A + marca todos os documentos da tela, e Esc limpa a seleção. Um clique simples continua abrindo o + documento. Uma barra fixa no pé da biblioteca mostra "N selecionados" e as ações em lote, gravadas de + uma só vez: **Favoritar**, **Marcar como concluído**, **Adicionar etiqueta**, **Remover da + biblioteca** (com **Desfazer** para todos) e **Limpar seleção**. Em Removidos, as ações são + **Restaurar** e **Excluir definitivamente**. A seleção vale para o que está na tela: trocar de + filtro a limpa, e uma busca ou um filtro que esconde um cartão o tira da seleção. +- **Cartões e calendário pelo teclado.** Os cartões da biblioteca são uma parada de Tab só (o cartão + ativo; nele, Tab percorre o botão do documento, o favorito e as opções). As setas movem o foco entre + os cartões (na grade, nas quatro direções, pela posição real na tela; na lista, ↑ e ↓), Home e End + vão ao primeiro e ao último, Enter abre e Espaço marca o cartão (Shift + Espaço, o intervalo). No + calendário do histórico os dias também são uma parada de Tab só: ← → andam um dia, ↑ ↓ uma semana, + Home e End vão ao começo e ao fim da semana, Page Up e Page Down trocam de mês, e Enter ou Espaço + escolhem o dia. +- **Esvaziar Removidos.** O botão no topo de Removidos e **Excluir definitivamente** no menu de cada + item excluem, depois de uma confirmação que mostra quantos documentos são e quanto espaço + liberam, as cópias de `pdfs/` e as capas de `covers/`. O catálogo é gravado primeiro, sem os + documentos; se um arquivo não puder ser excluído, o documento sai do catálogo mesmo assim e o aviso + diz quais arquivos ficaram. O arquivo original, fora da biblioteca, não é afetado, e o histórico + de leitura é mantido. Reimportar o mesmo PDF depois cria um documento novo. +- Busca textual no PDF, seleção, sumário, zoom, rotação, marcas e notas. +- **Barras do leitor.** O cabeçalho tem Voltar, o título, **Concluir leitura**, o favorito e o + menu **⋯**. A barra de ferramentas tem, à esquerda, a barra lateral, a busca e a página; à + direita, o zoom, o modo **Kindle** e as **duas páginas**, a marca da página, **Aparência** e a + tela cheia. O menu **⋯** reúne **Ler do início**, **Girar página** (tecla R, com **Desfazer**), + Meta de leitura…, Renomear na biblioteca…, Etiquetas…, Exportar uma cópia…, Exportar notas…, + Preferências…, Guia e atalhos… (F1) e Remover da biblioteca. O rodapé mostra o progresso à + esquerda e, à direita, a meta do dia e **Adicionar nota**. +- **Notas e realces no próprio texto.** Ao selecionar um trecho (com o mouse ou pelo teclado), uma + barra pequena aparece junto à seleção (acima da primeira linha, quando há espaço), sem cobri-la nem sair da janela: **Realçar** em + amarelo, verde, azul ou rosa, **Adicionar nota** (abre a nota com a citação e as linhas do trecho) e + **Copiar**. Ela some ao desfazer a seleção, ao rolar, ao clicar fora e com Esc. Com um trecho + selecionado, **H** realça na última cor usada e **N** adiciona uma nota (sem trecho, avisam como + selecioná-lo). Pelo teclado, com o foco nas páginas, **Shift + ← →** cresce a seleção por palavra + e **Shift + ↑ ↓** por linha, na rolagem e no Kindle (as setas sem Shift continuam virando páginas ou + telas); sem seleção, ela começa no primeiro texto à vista, fica no texto das páginas e a tela + acompanha o fim dela. O realce é desenhado sobre a página + em uma camada que não atrapalha a seleção nem os links, acompanha o zoom, a rotação, as duas + páginas lado a lado, o modo Kindle e a junção de margens, e é legível nos papéis claro, sépia e + escuro. Uma nota com texto ganha um pequeno ícone de nota na margem. Clicar num realce (ou no + ícone) abre um menu: **Editar nota**, **Mudar cor**, **Copiar texto** e **Excluir realce**, com + **Desfazer** no aviso. Uma seleção que atravessa páginas vira um realce por página; ao anotar, + vale o trecho da primeira página. A aba **Notas** da barra lateral tem busca (texto e citação, + sem diferenciar maiúsculas e acentos), a cor de cada realce ("Realce" quando não há texto) e o menu + da nota; clicar leva à página e à altura do realce, e isso conta como salto (Alt + ← volta). + **Exportar notas…** (na aba e no menu do documento) grava um arquivo `.md` com o título, o autor, + e as notas por página, cada uma com a citação, o texto e a data. +- Tela cheia com barras ocultas, reveladas pelo mouse nas bordas ou pelo teclado. + Os controles aparecem sobre o PDF sem deslocar a leitura, e uma linha fina na borda inferior + mostra o progresso enquanto eles estão ocultos. F11 alterna a tela cheia. - Retomada opcional de página, posição e zoom por documento. +- **Concluir leitura** no topo do leitor, com opção de desfazer, sem perder a posição. +- **Metas de leitura** diárias em páginas ou capítulos. A meta geral conta toda a leitura + do dia, em qualquer documento, também nos de Removidos e nos excluídos definitivamente. + Cada documento fica sem meta própria (só entra na meta geral) ou tem uma meta própria, que + também entra na meta geral. A biblioteca mostra o progresso da meta geral; o rodapé do + leitor mostra o da meta do documento, quando ele tem uma, ou o da geral. O diálogo tem duas + partes, cada uma com o seu **Salvar**: **Meta geral** (quantidade em páginas ou capítulos, ou + sem meta geral, com o progresso de hoje e uma barra) e, quando ele é aberto a partir de um + documento (menu do cartão, menu ⋯ ou rodapé do leitor), **Este documento**, com "Sem meta + própria" ou "Meta própria". Não há lista de documentos para escolher: para configurar outro, + abra o menu dele. **Ajustar leitura de hoje** (do documento aberto; pela biblioteca, o + documento é escolhido no próprio ajuste) e **Como a contagem funciona** ficam recolhidos no + fim, e **Ver histórico** é um link junto ao título. - Interface clara/escura/sistema, papel claro/sépia/escuro, tela quente e brilho - da leitura de 10% a 100%, combináveis e salvos automaticamente. + da leitura de 10% a 100%, combináveis e salvos automaticamente. O botão **Aparência**, na + barra de leitura, abre um painel com o papel, o brilho (**Restaurar**), a tela quente + (**Desativar**) e **Mais preferências…**, que leva à seção Aparência das Preferências; um ponto no + ícone avisa que o brilho ou a tela quente estão alterados. A tecla D percorre os papéis. - Modo Kindle com avanço instantâneo por telas e pequena sobreposição para - preservar linhas cortadas. Aceita teclado e roda do mouse. + preservar linhas cortadas. A opção **Unir conteúdo das páginas**, ativada por + padrão, reduz margens vazias entre páginas. Aceita teclado e roda do mouse. +- **Capítulos em nova tela** separa páginas de abertura reconhecidas pelo sumário + ou por cabeçalhos explícitos. O título aparece junto ao começo do capítulo, + após a última tela do trecho anterior. Pode ser desativado nas preferências. +- **Progresso e tempo restante.** O percentual vai de 0% na primeira página a 100% + na última, ou em documentos concluídos, e é o mesmo no rodapé do leitor (rolagem e + Kindle) e na biblioteca. Depois de cinco páginas lidas de um documento, o rodapé e a + faixa Continuar estimam o tempo restante no capítulo, quando há capítulos + reconhecidos, e no documento. +- **Duas páginas lado a lado** na rolagem contínua, para monitores largos. Escolha + **Duas páginas** ou **Duas páginas, capa separada** (a página 1 fica sozinha) no + botão ao lado do zoom, na tecla L ou nas preferências; a escolha vale para todos os + documentos e fica salva. Página anterior e próxima, as setas e os botões avançam por + par, e "Largura da página" e "Página inteira" consideram o par. O modo Kindle usa + uma coluna: ao ativá-lo a disposição fica de lado e ela volta quando você retorna à rolagem. -O layout original do PDF é preservado, incluindo margens e imagens. Não há +- **Barra lateral do leitor** com Sumário, Páginas, Marcas e Notas, que abre e fecha com + a tecla S. O sumário marca a seção que você está lendo e rola até ela sem tirar o foco; + os níveis se recolhem, e por padrão aparecem o primeiro nível e o caminho até a seção + atual; a lista é uma parada de Tab só (as setas andam pelos itens). A aba Páginas mostra miniaturas com o número de cada página, desenhadas só perto + da área visível, e marca a página atual ou as duas páginas de um par. A barra lembra se + estava aberta, a aba e a largura em todos os documentos. Arraste a borda dela, ou use as + setas, para mudar a largura (de 220 a 480 px, no máximo 40% da janela). +- **Voltar depois de um salto.** Ao usar o sumário, um link do PDF, o primeiro resultado da + busca, uma miniatura, uma marca ou nota, o campo de página, Home e End no Kindle ou **Ler do + início**, o Fólio guarda onde você estava e mostra sobre o PDF o botão "Voltar à pág. X" (e, + depois de voltar, "Avançar à pág. Y"), que some sozinho em cerca de 15 s. **Alt + ←** volta e + **Alt + →** avança, assim como os botões laterais do mouse sobre o leitor, por até 50 posições + enquanto o documento está aberto. Rolar e virar página não contam como salto. Para voltar à + biblioteca use o botão do topo ou **Ctrl + W**. +- **Menus que cabem na tela e funcionam pelo teclado.** O menu de opções de um documento, + o botão "Mais opções" do leitor e o painel Aparência abrem abaixo do botão + ou, quando faltar espaço, acima dele, sempre dentro da janela, e fecham ao rolar a + biblioteca ou redimensionar a janela. No menu, o foco vai ao primeiro item; use as setas, + Home, End ou a primeira letra de um item, Esc para fechar (o foco volta ao botão) ou Tab para + sair. Clique com o botão direito num documento da biblioteca, ou Shift + F10 (ou a tecla + Menu) com o foco nele, para abrir as mesmas opções; em Removidos, há Restaurar e Excluir definitivamente. + +O texto e as imagens mantêm sua diagramação. Para ver as margens originais, +desative a junção nas preferências ou volte à rolagem contínua. Não há refluxo de texto, OCR, edição de páginas, assinatura digital ou preenchimento -de formulários. O papel escuro inverte também as imagens. Notas e marcadores +de formulários. O papel escuro inverte também as imagens. As miniaturas mostram a página +inteira, também no Kindle com margens unidas, e seguem a rotação e o papel escolhidos. +O sumário só marca seções cujo destino aponta para uma página do documento; links +externos ou destinos quebrados continuam na lista, sem marca, com aparência de indisponíveis. Notas, realces e marcas ficam no catálogo, sem alterar o PDF. Senhas de PDFs não são armazenadas. +As metas contam páginas do PDF, não telas do Kindle: uma página conta após oito +segundos com o leitor ativo e apenas uma vez por dia. Com duas páginas lado a lado, +as duas que estão na tela contam juntas depois desses oito segundos, e uma página +do par que fica fora da janela (por exemplo, em zoom alto) não conta. Capítulos contam ao avançar +da última página acompanhada para o próximo início reconhecido, ou ao chegar ao +fim do documento. A contagem é uma estimativa de atividade, não uma confirmação +de que todo o conteúdo foi lido. Saltos não preenchem páginas intermediárias; +PDFs sem capítulos reconhecíveis permitem registro manual. O dia segue o relógio +local. O histórico mantém os totais antigos e compacta os IDs de páginas após os +31 registros mais recentes. A atualização +não inventa histórico anterior, e concluir um documento não soma suas páginas à meta. + +O percentual conta páginas do PDF, da primeira (0%) à última (100%), e não depende +do zoom, da janela ou das telas do Kindle. No Kindle, a última tela pode começar na +penúltima página: o leitor mostra 100% ao chegar ao fim do documento, mas a +biblioteca só conhece a página salva e mostra o valor dessa página. O tempo restante +usa o ritmo medido em segundos por página, só com leitura ativa e os mesmos critérios +das metas. Pausas longas e saltos são descartados, e o ritmo de cada documento fica nas +preferências (até 50 documentos). É uma estimativa: páginas mais densas ou mais leves +mudam o resultado, e nada é exibido antes de cinco páginas medidas. Com duas páginas +lado a lado, o tempo em um par é dividido entre as duas páginas. + +## Histórico de leitura + +Abra **Histórico de leitura** na biblioteca ou **Ver histórico** nas metas. O +calendário mensal mostra páginas e capítulos por dia, metas atingidas, dias +abaixo da meta e o dia atual em andamento. Filtre por documento e selecione um dia +para ver os totais e os documentos que contribuíram. Leituras de documentos sem meta +também são registradas. Registros de documentos removidos continuam disponíveis e, depois de +**Excluir definitivamente**, os dias de leitura do documento continuam contando no calendário (o documento +aparece no filtro, sem arquivo, e não volta à biblioteca nem a Removidos). + +O resultado usa a meta que valia naquele dia. A última alteração feita no próprio +dia prevalece, mas mudanças posteriores não reavaliam a meta de dias anteriores. Na +biblioteca inteira, cada dia conta toda a leitura daquele dia, em qualquer documento +(com meta própria ou sem), contra a meta geral do dia. Em um documento, o dia usa a +meta própria que ele tinha; sem meta própria, aparecem os totais dele e o status +**Sem meta própria**. Os dias passados são recalculados com essa regra. Dias com +totais antigos, mas sem configuração registrada, exibem **Meta não registrada**. O +aplicativo não reconstrói leituras que nunca foram salvas ou já foram descartadas +por versões anteriores. + ## Executar a partir do código Ambiente validado: Windows x64, Node.js 24, Rust com toolchain MSVC, @@ -61,7 +231,9 @@ Para compilar: npm run tauri -- build ``` -O instalador NSIS fica em `src-tauri/target/release/bundle/nsis/`. Não há +O instalador NSIS fica em `src-tauri/target/release/bundle/nsis/`. Ele registra o Fólio +em **Abrir com** para `.pdf` sem mudar o programa padrão (veja +[compatibilidade](docs/compatibility.md#abrir-com-e-o-programa-padrão-de-pdf)). Não há instalador público assinado nesta preparação. Para instalações antigas de Lume PDF, leia [compatibilidade](docs/compatibility.md) antes de distribuir uma atualização com o nome novo. @@ -75,6 +247,9 @@ o código. Não faz parte do aplicativo instalado. python -m pip install -r requirements-test.txt npm run build npm run check +npx playwright install chromium +python scripts/fixtures.py +npm run test:ui npm run test:native npm run package:source ``` @@ -85,6 +260,12 @@ de `test-results/native/`. Ela valida o destino antes de importar documentos e encerra somente o processo que iniciou. Não use uma biblioteca pessoal como fixture de teste. Evidências locais ficam em `test-results/` e não entram no ZIP. +`npm run test:ui` usa um navegador sem janela e um perfil próprio em +`test-results/reader-chrome/`. Exercita a interface, PDF.js e preferências reais, +com comandos nativos simulados. Pode ser executado sem abrir ou encerrar o leitor +instalado. Para usar um Edge já disponível, defina `FOLIO_BROWSER_CHANNEL=msedge`. +A suíte nativa abre uma janela de testes e complementa essa verificação com Tauri. + O [workflow de Windows](https://github.com/MakenRosa/folio/actions/workflows/ci.yml) é executado em pushes para `main` e pull requests. Os resultados documentados em `VALIDATION.md` são da execução local; o histórico remoto está no GitHub Actions. @@ -97,6 +278,8 @@ As instalações existentes usam `%LOCALAPPDATA%\com.lume.pdf`. `pdfs/` guarda a cópias, `covers/` as capas e `library.json` o catálogo com progresso e notas. `library.backup.json` permite recuperar o catálogo anterior. As preferências ficam no perfil WebView2. Para backup, feche o aplicativo e copie a pasta inteira. +O tamanho e a posição da janela ficam em `%APPDATA%\com.lume.pdf\.window-state.json`, e +podem ser apagados sem perda de dados. Rust faz a cópia em blocos e a persistência do catálogo. PDF.js é carregado sob demanda; o transporte lê faixas da cópia local e a renderização limita canvases diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index bdee1ca..45e4738 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -3,14 +3,14 @@ O código original do Fólio usa a [licença MIT](LICENSE). Esse licenciamento não substitui as licenças de bibliotecas, fontes ou codecs utilizados pelo aplicativo. -| Componente direto | Uso | Licença declarada | -| ------------------------ | ------------------------------- | ----------------- | -| PDF.js | Renderização e busca no PDF | Apache-2.0 | -| Tauri e plugins oficiais | Janela, WebView2 e diálogos | MIT ou Apache-2.0 | -| Serde e serde_json | Serialização do catálogo | MIT ou Apache-2.0 | -| sha2 | Deduplicação por conteúdo | MIT ou Apache-2.0 | -| uuid | Identificação das cópias | MIT ou Apache-2.0 | -| tempfile | Gravação por arquivo temporário | MIT ou Apache-2.0 | +| Componente direto | Uso | Licença declarada | +| ------------------------ | --------------------------------------------- | ----------------- | +| PDF.js | Renderização e busca no PDF | Apache-2.0 | +| Tauri e plugins oficiais | Janela, WebView2, diálogos e estado da janela | MIT ou Apache-2.0 | +| Serde e serde_json | Serialização do catálogo | MIT ou Apache-2.0 | +| sha2 | Deduplicação por conteúdo | MIT ou Apache-2.0 | +| uuid | Identificação das cópias | MIT ou Apache-2.0 | +| tempfile | Gravação por arquivo temporário | MIT ou Apache-2.0 | As versões exatas estão em `package-lock.json` e `src-tauri/Cargo.lock`. `scripts/notices.mjs` recolhe os textos de licença dos pacotes instalados e os diff --git a/VALIDATION.md b/VALIDATION.md index ffe23e7..f1b1192 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,6 +1,183 @@ -# Validação do Fólio 1.3.0 +# Validação do Fólio -Execução local em 22/10/2026, Windows x64 e WebView2. Foram usados exclusivamente +## Não publicado: barra lateral do leitor + +Execução local em 29/10/2026, Windows x64, com Chromium sem janela, PDF.js real e +comandos nativos simulados (`tests/support/headless-app.mjs`). Passaram a formatação, +a verificação de tipos, 64 testes unitários de TypeScript, a compilação e os 46 +cenários de `npm run test:ui`, 13 deles novos em `tests/sidebar.mjs`. O arquivo usa um +PDF artificial de 60 páginas, uma delas em paisagem, com sumário aninhado e destinos +nomeados, gerado na memória por `tests/support/outline-pdf.mjs`: + +- A barra começa fechada, com `aria-expanded`, tooltip com a tecla S e a alça como + `separator` vertical com valores mínimo, máximo e atual. +- O sumário marca a seção atual com `aria-current`, abre o caminho até ela e acompanha a + leitura, inclusive com destinos nomeados e uma página em paisagem. O contraste do texto + marcado fica acima de 4,5 nos dois temas, e o destaque tem peso e barra, não só cor. +- A seção entra na área visível com `block: 'nearest'`, sem tirar o foco do campo da + página, e a lista não volta sozinha enquanto a seção é a mesma. +- Os ramos abrem e fecham com o mouse, Enter, Espaço e as setas; as setas não viram + página, e Enter e o clique levam à seção. +- A aba Páginas cria 60 botões, mas desenha só as miniaturas perto da área visível, + com espaço na proporção da página. Ao rolar todo o livro nunca houve mais de 16 + canvases, as primeiras miniaturas foram liberadas e a página em paisagem tomou a forma real. +- A rotação refaz as miniaturas, e fechar o livro com a lista desenhando não deixa + canvases nem erros. +- Clique, Enter, Espaço, setas, Page Down, Home e End levam à página; a página atual é + marcada, o par é marcado em duas páginas, só uma página em zoom alto, e uma em Kindle. +- Marcas e Notas seguem funcionando, e as setas trocam de aba sem virar a página. +- Aba, abertura e largura valem para outro livro e depois de reiniciar o navegador. +- A alça só grava a largura ao soltar (mouse) ou ao soltar a tecla; respeita os limites, + tem foco visível e as setas dela não viram página. +- A posição de leitura se mantém ao alargar e estreitar a barra, na rolagem, em duas + páginas e no Kindle (diferença de até 0,06 de página). +- Em 800 px a barra tem no máximo 320 px, o PDF mantém ao menos 440 px, e cabeçalho, + barra do leitor, rodapé e abas não transbordam (`scrollWidth` igual a `clientWidth`) nos + temas claro e escuro, com os três papéis. A largura escolhida volta numa janela maior. +- A tecla S alterna a barra, não age ao digitar, conta uma vez ao segurar e devolve o foco + à leitura quando a barra some. O guia lista o atalho. + +Os testes unitários cobrem a resolução de destinos, a seção atual (empates e sumário +fora de ordem), o recolhimento, os limites e os passos de largura, o teto de canvases e a +validação das três novas preferências. Os cenários anteriores de controles, metas, +histórico, progresso e duas páginas foram reexecutados e passaram. A suíte nativa +(`npm run test:native`), os testes Rust, os cenários Kindle e de capítulos (`kindle.mjs`, +`joined-pages.mjs`, `chapters.mjs`, que precisam da janela nativa) e o leitor de tela não +foram executados. Uma verificação à parte confirmou que os inícios de capítulo no Kindle +continuam sendo 2, 4 e 6 no PDF de seis páginas. Foram usados somente documentos +artificiais e perfis isolados; relatório em `test-results/sidebar/results.json`. + +## Não publicado: duas páginas lado a lado + +Execução local em 29/10/2026, Windows x64, com Chromium sem janela, PDF.js real e +comandos nativos simulados (`tests/support/headless-app.mjs`). Passaram a formatação, +a verificação de tipos, 45 testes unitários de TypeScript, a compilação e os 33 +cenários de `npm run test:ui`, nove deles novos em `tests/spread.mjs`: + +- O botão ao lado do zoom forma pares (`.spread`) e volta à página única; "Largura da + página" e "Página inteira" mantêm o par na janela, sem barra horizontal. +- "Capa separada" deixa a página 1 sozinha e forma 2–3, 4–5; o botão restaura a última + disposição escolhida. +- Próxima e anterior, as setas e os botões avançam por par, com início e fim desabilitados + nas duas disposições. +- O Kindle força uma coluna sem apagar a preferência, desabilita o botão e o seletor + com explicação, e a volta à rolagem restaura a disposição e o ponto de leitura. +- Trocar a disposição mantém o ponto lido em coordenadas do PDF. +- A tecla L alterna a disposição e é ignorada ao digitar; o botão responde ao teclado. +- As duas páginas de um par contam nas metas após oito segundos, e o ritmo mede dois + passos de duas páginas como segundos por página. +- A disposição, a página e a posição sobrevivem ao fechamento do livro e à reinicialização + do navegador. +- Janela de 800 px, tema escuro e papel sépia sem transbordar o cabeçalho, a barra do + leitor e o rodapé (`scrollWidth` igual a `clientWidth`), também em 900 px e após redimensionar. + +Os testes unitários cobrem o agrupamento de páginas (comparado com o laço do PDF.js), +a preferência `spread`, a contagem de páginas e capítulos nas metas e o ritmo em passos +de duas páginas. Os cenários de metas, histórico, progresso e controles anteriores foram +reexecutados e passaram. A suíte nativa (`npm run test:native`) e os testes Rust não +foram executados nesta verificação. Foram usados somente documentos artificiais e +perfis isolados; relatórios em `test-results/spread/results.json`. + +## 1.8.0: histórico de leitura + +Execução local em 29/10/2026, Windows x64. Passaram 18 testes unitários de +TypeScript, seis testes Rust, formatação, verificação de tipos e Clippy. +A suíte de interface passou 16 cenários, incluindo seis novos para o histórico: + +- Calendário com páginas, capítulos e resultados das metas em cada dia. +- Filtro por livro, metas compartilhadas e próprias, ausência de meta e registros de livros removidos. +- Meses anteriores e registros antigos cuja meta não é conhecida. +- Mudança de meta sem reescrever resultados anteriores e reinício do navegador. +- Tema escuro, teclado e janela de 800 × 600 sem transbordamento horizontal. +- Leitura sem meta salva no histórico após tempo ativo no leitor. + +A suíte nativa completa também passou. A verificação de reinício real confirmou +a linha do tempo das metas e exibiu os totais salvos no histórico pela interface. +Os testes Rust verificaram migração idempotente, alteração de meta por data, +rejeição de data inválida, persistência de mais de 31 dias e restauração. +Não houve erros JavaScript ou pedidos externos nos cenários observados. + +Foram usados documentos artificiais e perfis isolados. Os relatórios ficam em +`test-results/history/results.json`, `reader-chrome/results.json`, +`goals/results.json`, `kindle/restart.json` e `native//result.json`. +Registros que versões anteriores nunca salvaram ou já descartaram não podem +ser reconstruídos por esta atualização. + +O executável de produção 1.8.0 foi instalado e aberto na instalação local. +O calendário e os totais existentes foram conferidos pela interface real. +A comparação com o backup confirmou PDFs idênticos pelo SHA-256, catálogo, +preferências, posições, conclusão, metas e totais anteriores preservados. +O executável instalado corresponde ao artefato de produção verificado. + +As tentativas anteriores foram bloqueadas pelo Controle Inteligente de +Aplicativos, com restauração da versão 1.7.0. A instalação bem-sucedida ocorreu +após o usuário desativar esse controle manualmente. A automação não alterou +as políticas de segurança do Windows. + +## 1.7.0: conclusão e metas diárias + +Execução local em 29/10/2026, Windows x64. Passaram 14 testes unitários de +TypeScript, cinco testes Rust, verificação de tipos, formatação e Clippy. +Os testes Rust incluem abertura de catálogo antigo sem perda de campos, +persistência de conclusão e metas, restauração e rejeição atômica de dados inválidos. + +Os cinco cenários de controles da versão 1.6 foram reexecutados. A nova suíte +`tests/goals.mjs`, com Edge sem janela, PDF.js real e biblioteca nativa simulada, +passou estes cinco cenários: + +- Concluir e desfazer, preservando posição e estado após reabrir o documento. +- Soma global com vários livros, meta própria, ausência de meta, páginas, + capítulos, validação de quantidade e substituição dos totais manuais. +- Página contabilizada por leitura ativa, deduplicação diária, pausa com diálogo + aberto e capítulo reconhecido na transição para o seguinte. +- Tema escuro, janela de 800 × 600 e persistência após reiniciar o navegador. +- Virada da data local sem atribuir um formulário do dia anterior ao novo dia. + +A suíte nativa completa foi reexecutada com identidade, biblioteca e perfil +isolados. Passaram importação, busca, notas, marcadores, PDFs protegidos e +corrompidos, retomada, brilho, Kindle, junção de páginas e separação de capítulos. +O teste de reinício real também salvou pela interface uma meta própria, os +totais diários e a conclusão. Todos permaneceram após encerrar e reabrir o +processo. A posição do PDF teve diferença de zero pixels. + +Não houve erros JavaScript ou pedidos externos nos cenários observados. Foram +usados PDFs artificiais; a biblioteca instalada não participou dessas suítes. +Relatórios: `test-results/goals/results.json`, `reader-chrome/results.json`, +`kindle/restart.json` e `native//result.json`. A contagem por atividade +continua sendo uma estimativa, ajustável pelo usuário, e não mede compreensão. + +## 1.6.0: controles e preferências + +Execução local em 27/10/2026, Windows x64, com Edge sem janela e perfil de teste +isolado. O aplicativo instalado permaneceu em uso e não foi reinstalado, +encerrado ou usado nos testes. Foram usados apenas PDFs artificiais. + +Os cinco cenários de `npm run test:ui` passaram: + +- Brilho e tela quente na barra alteram a aparência real do PDF e permanecem + sincronizados com o diálogo de preferências. O modo foco não está presente. +- As barras somem e reaparecem por bordas independentes, mantendo dimensões e + posição do PDF. Um ajuste aberto mantém os controles acessíveis. +- Teclado e busca revelam os controles; Esc fecha primeiro o ajuste ou a busca, + e F11 alterna a tela cheia, restaurando as barras ao sair. +- Os controles cabem em 800 × 600 e respeitam movimento reduzido. +- Tema escuro, Kindle, brilho, tela quente, visualização em lista, ordenação + e retomada permanecem após fechar e reabrir o processo do navegador de teste. + +Não houve erros JavaScript ou requisições externas observados. A interface e +PDF.js são reais; as chamadas de janela e biblioteca Tauri são simuladas nessa +suíte. Isso verifica o comportamento visual, mas não comprova a transição de +tela cheia do Windows. A suíte nativa abaixo é evidência histórica da versão +1.5.0 e não foi reexecutada nesta rodada para não interromper a leitura em uso. +Relatório: `test-results/reader-chrome/results.json`. + +Também passaram a compilação de produção do frontend e `npm run check`: +formatação, TypeScript, dez testes unitários de interface/domínio, três testes +Rust, `cargo fmt` e Clippy sem avisos. Nenhum instalador foi executado nesta rodada. + +## 1.5.0: validação nativa anterior + +Execução local em 25/10/2026, Windows x64 e WebView2. Foram usados exclusivamente PDFs artificiais e um perfil próprio de testes. O aplicativo instalado e a biblioteca pessoal não participaram da suíte. @@ -11,6 +188,8 @@ biblioteca pessoal não participaram da suíte. | Integração nativa | 14 cenários aprovados | | Brilho | 6 cenários aprovados | | Leitura por telas | 12 cenários aprovados | +| União de páginas | 4 cenários aprovados; retomada com diferença de 0 pixels | +| Separação de capítulos | 4 cenários aprovados; 24 linhas anteriores preservadas | | Retomada ao reabrir o PDF | Diferença de 0 pixels; opção de abrir do início aprovada | | Retomada após encerrar o processo | Modo Kindle e posição preservados, diferença de 0 pixels | | PDF de 300 páginas | Navegação até a página 285, 4 canvases ativos | @@ -27,16 +206,31 @@ da página visível e o campo de navegação. O número digitado podia ser sobre antes da confirmação. O campo agora preserva o rascunho enquanto tem foco. A regressão digita 285, provoca rolagem e verifica o valor antes de navegar. +Na versão 1.4.0, o PDF artificial de margens passou de 1122 para 567 pixels de +altura por página a 100% de zoom. O intervalo entre a última linha de uma página +e a primeira da seguinte ficou em 21,67 pixels, mantendo todas as linhas. +A suíte também verificou busca, rodapé, imagem próxima à borda, texto quase +branco, página vazia, zoom, rotação e retorno às margens originais. O SHA-256 da +cópia importada permaneceu igual ao original após essas operações. + +Na versão 1.5.0, a suíte verificou o título em uma tela própria com texto do novo +capítulo, todas as 24 linhas anteriores acessíveis, retorno ao trecho anterior, +detecção com e sem sumário, páginas comuns unidas, zoom, redimensionamento, +capítulo final curto, busca, desativação da opção e retomada. O PDF permaneceu +idêntico pelo SHA-256. Os testes de cálculo percorrem combinações de altura, +zoom e escala de pixels para conferir cobertura sem lacunas entre capítulos. + ## Verificações de código -`npm run check` reúne formatação, TypeScript, cinco testes de preferências, -compatibilidade e cálculo de telas, três testes Rust de domínio, `cargo fmt` e +`npm run check` reúne formatação, TypeScript, dez testes de preferências, +compatibilidade, capítulos, margens e cálculo de telas, três testes Rust de domínio, `cargo fmt` e Clippy com avisos tratados como erro. Os testes nativos complementam esses checks com uma janela real de WebView2. Para reproduzir, siga os comandos do [README](README.md). Os relatórios locais são `test-results/results.json`, `position.json`, `brightness/results.json`, -`kindle/results.json`, `kindle/restart.json` e `native//result.json`. +`kindle/results.json`, `kindle/restart.json`, `joined-pages/results.json`, `chapters/results.json` e +`native//result.json`. Esses arquivos podem conter caminhos do ambiente e não são distribuídos no ZIP. ## Limites da evidência diff --git a/docs/architecture.md b/docs/architecture.md index 34591c4..931a051 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,25 +2,38 @@ ## Fluxo principal -1. O diálogo nativo entrega caminhos escolhidos pelo usuário. -2. Um comando Tauri executa a importação fora da thread da interface. -3. Rust copia cada arquivo em blocos de 256 KiB, calcula SHA-256 e verifica duplicatas. +1. O diálogo nativo (arquivos ou uma pasta), ou o arrastar e soltar, entrega caminhos escolhidos pelo usuário. +2. Um comando Tauri executa a importação fora da thread da interface. Pastas viram os PDFs que + contêm, em qualquer profundidade (sem seguir links simbólicos nem junções); o andamento conta + esses arquivos. +3. Rust copia cada arquivo em blocos de 256 KiB, calcula SHA-256 e verifica duplicatas. O resultado + diz quais livros entraram e quais já existiam (id e título). 4. O catálogo guarda um UUID e os metadados. O caminho original deixa de ser necessário. -5. Ao abrir um documento, a interface carrega PDF.js sob demanda e lê a cópia local. -6. O leitor envia página, coordenadas do PDF, zoom e rotação para o catálogo. +5. Uma fila em segundo plano (`src/library/`) abre os livros novos, ou sem capa ou número de + páginas, um por vez, e grava capa, páginas, autor e (se ainda for o nome do arquivo) título. +6. Ao abrir um documento, a interface carrega PDF.js sob demanda e lê a cópia local. +7. O leitor envia página, coordenadas do PDF, zoom e rotação para o catálogo. ## Responsabilidades -| Área | Arquivos | Responsabilidade | -| ---------------------- | --------------------------- | ---------------------------------------------------------------- | -| Coordenação | `src/main.ts` | Fluxos de abertura, fechamento, importação e edição de metadados | -| Interface | `src/ui/` | Biblioteca, estrutura das telas, diálogos, atalhos e mensagens | -| Preferências | `src/preferences.ts` | Compatibilidade, validação e armazenamento de opções visuais | -| Leitor | `src/reader.ts` | PDF.js, leitura por faixas, busca, zoom, rotação e ciclo de vida | -| Telas de leitura | `src/reading/screens.ts` | Cálculo de avanço, sobreposição e retorno nos limites | -| Inicialização nativa | `src-tauri/src/main.rs` | Janela, plugins e abertura da biblioteca | -| Comandos nativos | `src-tauri/src/commands.rs` | Contratos IPC e execução de operações locais | -| Domínio e persistência | `src-tauri/src/library.rs` | Importação, catálogo, recuperação, notas e marcadores | +| Área | Arquivos | Responsabilidade | +| ---------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Coordenação | `src/main.ts` | Fluxos de abertura, fechamento, importação e edição de metadados | +| Interface | `src/ui/` | Biblioteca, estrutura das telas, diálogos, atalhos e mensagens | +| Preferências | `src/preferences.ts` | Compatibilidade, validação e armazenamento de opções visuais | +| Leitor | `src/reader.ts` | PDF.js, leitura por faixas, busca, zoom, rotação e ciclo de vida | +| Abertura do PDF | `src/pdf-source.ts` | Transporte por faixas e capa, comuns ao leitor e à fila | +| Fila da biblioteca | `src/library/metadata.ts`, `queue.ts`, `enrich.ts` | Título e autor plausíveis, fila que pausa e o trabalho por livro | +| Telas de leitura | `src/reading/screens.ts` | Cálculo de avanço, sobreposição e retorno nos limites | +| Duas páginas | `src/reading/spread.ts` | Pares de páginas, páginas em vista e correspondência com PDF.js | +| Barra lateral | `src/reading/outline.ts`, `sidebar.ts`, `thumbnails.ts` | Seção atual do sumário, limites de largura e de canvases | +| Barra lateral (tela) | `src/ui/outline-view.ts`, `thumbnail-list.ts`, `sidebar-resizer.ts` | Árvore do sumário, lista de miniaturas e alça de largura | +| Progresso e ritmo | `src/reading/progress.ts`, `src/reading/pace.ts` | Percentual único e tempo restante estimado | +| Voltar após saltos | `src/reading/jumps.ts`, `src/ui/jump-pill.ts` | Pilhas de posições na sessão do documento e botão flutuante | +| Inicialização nativa | `src-tauri/src/main.rs` | Janela, plugins e abertura da biblioteca | +| PDFs de fora | `src-tauri/src/launch.rs` | Argumentos da primeira e da segunda instância, estado e evento | +| Comandos nativos | `src-tauri/src/commands.rs` | Contratos IPC e execução de operações locais | +| Domínio e persistência | `src-tauri/src/library.rs` | Importação, catálogo, recuperação, notas e marcadores | ## Escolhas e limites @@ -47,18 +60,342 @@ Canvases são renderizados sob demanda, com teto de 8 megapixels por canvas. global de pixels quando o zoom ou o tamanho da janela muda. Há debounce de gravação durante a leitura e salvamento final no fechamento normal. +**Controles de leitura.** O cabeçalho do leitor tem Voltar, o título, Concluir leitura, o +favorito e o menu ⋯; a barra de ferramentas tem a barra lateral, a busca e a página à esquerda e, +à direita, o zoom, o Kindle e as duas páginas, a marca, Aparência e a tela cheia. Girar página +e Ler do início não têm botão: estão no menu ⋯ (`bookMenu`, na ordem do leitor), e a tecla R +continua girando a página (as teclas D e R chamam `cyclePaper` e `rotate`, de `bindShortcuts`). +`src/ui/reading-comfort.ts` é o botão Aparência (`#reader-appearance`) e o painel que ele abre +(`role="dialog"`: os três papéis, brilho com "Restaurar", tela quente com "Desativar" e "Mais +preferências…", que abre as Preferências na seção Aparência); ele compartilha as preferências de +brilho e tela quente com o diálogo de Preferências, e um ponto no ícone avisa que o brilho está +abaixo de 100% ou a tela quente acima de 0%. Tema, papel, modo Kindle, +conforto e opções da biblioteca continuam na chave existente `lume.preferences`. +O diálogo de Preferências (`src/ui/preferences-dialog.ts`) tem quatro seções em abas +(Aparência, Leitura, Teclado e Biblioteca); `showPreferences(prefs, ações, seção)` abre +direto numa delas e, sem a seção, volta à última usada na sessão. +`src/ui/reader-chrome.ts` acompanha a tela cheia nativa e revela cada barra pela +borda correspondente ou pelo foco do teclado. Os menus sobrepõem a área do leitor, +sem redimensionar o PDF quando aparecem. Busca e ajustes abertos mantêm os controles +acessíveis; a animação respeita a preferência de movimento reduzido do sistema. +A tela cheia é um estado da janela, não uma preferência restaurada na abertura. +`tauri-plugin-window-state` lembra tamanho, posição e se a janela estava maximizada, sem +`FULLSCREEN`; ao fechar em tela cheia, o frontend sai dela antes de destruir a janela, para que +o tamanho guardado seja o da janela e não o do monitor. Depois da restauração, `main.rs` +confere se a janela cabe em algum monitor e, se não couber, reduz e centraliza. + +**PDFs vindos do Explorer.** O PDF chega como argumento da linha de comando. `launch.rs` resolve +os caminhos (os relativos pela pasta do processo que foi iniciado), mantém só os `.pdf` +existentes e os guarda em `PendingFiles`. O evento `open-files`, emitido quando uma segunda +instância entrega seus argumentos, só avisa que há arquivos: o frontend os pega com +`take_pending_files`, que esvazia a lista. O estado existe porque o aviso pode chegar antes de a +janela escutar (o Explorer abre vários arquivos selecionados em vários processos quase juntos). +No frontend, `openLaunched` importa por `import_pdfs`, abre o primeiro livro com `openBook` e +espera uma importação ou um livro em carga. + **Modo Kindle.** A área visível avança sobre uma sequência de páginas sem intervalos externos. Há sobreposição para recuperar linhas cortadas, alinhamento a pixels -físicos e tratamento do último trecho parcial. O layout original, as margens -internas e as imagens são preservados. Não é conversão de PDF em EPUB. +físicos e tratamento do último trecho parcial. A junção reduz as margens vazias +superiores e inferiores usando uma miniatura do canvas já renderizado. Texto e +áreas de anotações limitam o recorte, incluindo texto claro. Páginas em branco, +fundos incertos e texto girado/inclinado mantêm as dimensões completas. Cabeçalhos, +rodapés e imagens não são descartados. Não é conversão de PDF em EPUB. + +O recorte existe somente na área de exibição em memória do PDF.js. As camadas +de seleção, busca e navegação usam as mesmas coordenadas do documento. Um cache +local descartável guarda até mil páginas por documento e oito documentos; ele +evita refazer análises e recompõe os tamanhos ao retomar a leitura. Nenhuma página +é rasterizada só para detectar margens, e os bytes do PDF continuam intactos. + +**Aberturas de capítulos.** O sumário fornece páginas de início, com deduplicação +de títulos e subtítulos na mesma página. Cabeçalhos explícitos de capítulo também +são reconhecidos nas primeiras linhas de páginas já renderizadas. Não há OCR nem +varredura de texto de todo o documento. Sem sumário ou cabeçalho reconhecível, a +página continua unida às demais. A opção pode ser desativada independentemente da +redução de margens. + +O recorte temporário da área visível impede mostrar o próximo capítulo no rodapé +da tela atual. O avanço só alcança esse início depois que todo o trecho anterior +pôde ser visto. O próximo título alinha ao topo, e a volta recupera o último trecho. +Os pontos de separação acompanham as dimensões atuais das páginas; não são +gravados como coordenadas de leitura e não alteram o arquivo. Essa função separa +páginas de abertura completas, não subdivide uma página que contenha vários títulos. **Offline durante o uso.** PDFs, fontes e módulos de renderização estão locais. Desenvolvimento e instalação de dependências usam a rede. Links externos no PDF não são abertos automaticamente. Não há conta, sincronização ou backend remoto. +**Conclusão e metas.** A conclusão é uma escolha explícita independente da posição. +`src/reading/goals.ts` trata a data local e a contagem. A meta geral +(`globalCount`) soma o `dayCount` de todos os documentos com registro no dia, qualquer +que seja a configuração de cada um, inclusive os de Removidos e os registros `archived` +(`countedBooks`, em `main.ts`, monta essa lista para "hoje" e para o histórico). Cada +documento tem duas opções: sem meta própria (`inherit`, e também `none`, que catálogos +antigos guardam e `ownGoal` lê do mesmo jeito) ou meta própria (`custom`). `goalProgress` +diz o progresso de um escopo: a meta do documento, quando ele tem uma, ou a geral. O +rodapé do leitor usa essa regra, e a biblioteca mostra sempre a meta geral. O diálogo +(`src/ui/goals-dialog.ts`) segue a mesma regra em duas partes: a meta geral e, só quando é +aberto a partir de um documento, a meta própria dele; não há lista de documentos para +escolher. A meta geral fica +nas preferências existentes; a meta própria de cada documento e os registros diários ficam +no catálogo. O Rust continua aceitando os três valores de `mode` em `readingGoal` e em +`goalHistory`, e a interface grava `inherit` para "sem meta própria". Campos novos recebem valores padrão ao abrir +catálogos antigos, sem migrar arquivos ou alterar os bytes dos PDFs. + +Uma amostra por segundo acompanha a página atual apenas com foco no leitor e sem +diálogo, busca ou menu aberto. Oito segundos qualificam uma página; IDs evitam +contagem repetida no mesmo dia. Uma transição adjacente para um início de capítulo +reconhecido qualifica o anterior. O último capítulo pode ser contado na tela final. +Os inícios são compartilhados com a navegação por capítulos, inclusive em rolagem +contínua, sem varredura integral do PDF. PDFs sem títulos ou sumário reconhecível +precisam do ajuste manual. Esses critérios estimam atividade, sem inferir compreensão. + +O registro mantém IDs de páginas/capítulos e ajustes dos totais, preservando a +deduplicação depois de uma correção manual. Os totais históricos são mantidos; após 31 registros, IDs são compactados em +totais. O limite defensivo do catálogo é de 36.500 dias por livro. +O backend valida datas, limites e IDs antes da gravação atômica. Mudanças de +configuração não apagam o registro e a conclusão não gera crédito retroativo. + +**Histórico.** `src/reading/history.ts` resolve a meta vigente por data local. +Alterações da meta geral ficam em uma linha do tempo nas preferências, e as +alterações por documento ficam no catálogo. A inicialização adiciona o primeiro +registro aos livros existentes em uma única gravação, sem inventar metas antigas. +A configuração final de cada dia prevalece naquele dia. Para a biblioteca inteira, +`historyDay` conta toda a leitura do dia, de todos os documentos, contra a meta geral +que valia naquele dia; a configuração de cada documento não entra nessa conta, e os +dias passados são recalculados com a mesma regra de hoje. Para um documento, vale a +meta própria que ele tinha no dia; sem ela, o resultado mostra só os totais, com o +status `no-own` ("Sem meta própria"). Totais de livros +removidos continuam disponíveis no histórico, assim como os de livros excluídos +definitivamente (ver **Biblioteca que escala**). O calendário calcula somente o +mês exibido, sem carregar PDFs. A leitura de páginas e capítulos também é +acompanhada sem meta, usando o relógio monotônico para medir tempo ativo. + +**Progresso e tempo restante.** `src/reading/progress.ts` é a única definição do +percentual: da primeira página (0%) à última (100%), com `finished` em 100%. Usa só +`page`, `pages` e `finished`, que o catálogo já guarda, por isso o rodapé (rolagem e +Kindle), a faixa Continuar e as barras da biblioteca coincidem. O percentual +de rolagem do contêiner foi abandonado porque muda com a janela, o zoom e a junção de +páginas. Ao chegar ao fim do documento, o leitor mostra 100% mesmo quando a última tela +começa na penúltima página; a biblioteca não tem esse dado, e a diferença só existe +nesse caso. Guardar uma fração dentro da página exigiria medir cada página, e o +catálogo continua intacto. + +`src/reading/pace.ts` mede segundos por página com a mesma amostra por segundo das +metas (foco, sem diálogo, busca ou menu). Uma página só é medida ao chegar a ela e sair +dela por passos curtos para frente (até três páginas, porque telas do Kindle podem +pular uma). A primeira página da sessão, saltos, retornos, interrupções, trocas de +livro e pausas do relógio não geram medida. Abaixo de 3 s a página só foi folheada; acima de +4 min conta 4 min, e acima de 10 min é pausa e é descartada. A média é simples no +início e móvel (cerca de dez páginas) depois, com limite para valores extremos. Fica em +`lume.preferences`, por livro, com até 50 livros (os menos recentes saem primeiro), +normalizada ao carregar. Há estimativa com cinco páginas medidas. O tempo conta a página +atual; o capítulo termina no próximo início de `reader.chapterStarts`. Como esses inícios +vêm do sumário e das páginas já renderizadas, o fim do capítulo pode ser mais distante +que o real. O último capítulo e livros sem capítulos reconhecidos mostram só o livro. + +**Duas páginas lado a lado.** `spread` é uma preferência global (`none`, `odd`, +`even`) que usa o `spreadMode` do `PDFViewer`: `odd` forma os pares 1–2, 3–4, e `even` +deixa a página 1 sozinha e forma 2–3, 4–5 (a capa separada). Só vale na rolagem. O Kindle +depende de uma coluna, porque junta páginas e calcula as telas pelo deslocamento; ao +ativá-lo, `Reader.setMode` aplica a página única no viewer sem alterar a preferência, e a +volta à rolagem a restaura. A ordem é a mesma da junção: primeiro as caixas das páginas, +depois a disposição, e então `restoreAnchor` devolve o ponto de leitura em coordenadas do +PDF. Em um par, esse ponto fica na página da esquerda, que tem o topo da linha. PDF.js +refaz "Largura da página" e "Página inteira" para o par. Os pares usam margens e um +preenchimento menores em `ui.css`, para que a largura de dois PDFs caiba sem barra +horizontal. + +Página anterior e próxima, as setas e os botões usam `nextPage()` e `previousPage()` do +PDF.js, que avançam um par e, se só uma das páginas está inteira na tela, uma página. O +botão do início e do fim considera o par da página atual. `spreadPages` repete o +agrupamento do PDF.js (com um teste que copia o laço dele) para os limites e para +as metas. A página "atual" de PDF.js pode ser qualquer uma do par, então o leitor +acompanha a primeira do par como identidade da tela. + +As metas contam as duas páginas em vista. Decisão: as duas estão na tela pelo mesmo +tempo, e contar só uma exigiria 40 páginas para uma meta de 20, o que pune quem lê em +monitor largo. Uma página do par conta quando metade da largura e um quarto da altura +(ou da janela, se menor) estão visíveis, então um zoom alto em que só uma página cabe +volta a contar uma. `ReadingActivity` recebe `page` e `last` e devolve listas de páginas +e capítulos. Um capítulo que começa na segunda página do par (o caso comum em livros, em +páginas ímpares com a capa separada) conclui o capítulo anterior quando o par qualifica; +um início na primeira página conclui o anterior na chegada, como na página única. O ritmo +recebe a primeira página do par, então dois passos de duas páginas medem segundos por +página, dentro do limite de três páginas. Notas com trecho selecionado usam a página +do trecho (`data-page-number`), não a página "atual". + +**Voltar após saltos.** `src/reading/jumps.ts` guarda duas pilhas (voltar e avançar, até 50 +posições cada, só na memória e só enquanto o documento está aberto). Uma posição é a âncora +em coordenadas do PDF que o leitor já usa (`pageNumber`, `top`, `left`), por isso vale depois de +mudar o zoom, a rotação ou o modo; `Reader.restoreAnchor` a restaura, zerando o retorno de borda +do Kindle. `Reader` registra a posição de partida antes do salto e a mantém só se a chegada +ficou em outra página (ou, nos destinos de links e do sumário, a mais de uma tela na mesma +página); um salto mantido limpa o avançar. Os links internos, o sumário e as ações nomeadas +`FirstPage`, `LastPage`, `GoBack` e `GoForward` passam pelo `PDFLinkService`, cujos +`goToDestination`, `goToPage` e `executeNamedAction` são embrulhados no construtor. `jumpTo` +atende miniaturas, marcas, notas, o campo de página e "Ler do início"; `boundary`, Home e End +do Kindle. Na busca, a posição de partida é guardada ao chamar `find` e só vira salto no primeiro +resultado encontrado em outra página; os resultados seguintes da mesma busca não contam, e +fechar a busca, outro salto ou voltar encerram a "sessão". Rolar e virar página (`turn`, +`turnPair`, `goTo`) não passam por aqui. `main.ts` zera o `PaceTracker` a cada salto; as metas +já ignoram as páginas intermediárias, que nunca estão na tela. O botão (`src/ui/jump-pill.ts`) +fica dentro de `#viewer-wrap`, em posição absoluta, para não deslocar o PDF. Alt + ← e +Alt + →, e os botões laterais do mouse (capturados no documento para o WebView não os tratar +como navegação), chamam `Reader.back` e `Reader.forward`. + +**Barra lateral.** `src/reading/outline.ts` resolve uma vez cada destino do sumário para +um número de página (`resolveOutline`); a mesma árvore alimenta os inícios de capítulo +(`ChapterBreaks.outline`) e o destaque da seção, sem duplicar a leitura de destinos +nomeados e referências. A seção atual é o último item, na ordem do sumário, que começa na +página atual ou antes dela (`current.page`, a mesma do rodapé): um capítulo novo encerra as +subseções do anterior, e na mesma página vence o mais fundo ou o posterior. Itens sem página +(link externo, destino quebrado) nunca são marcados. Por padrão ficam abertos só o primeiro +nível e os ramos que contêm a seção; o usuário abre e fecha à mão, e entrar em outra seção +reabre o caminho até ela. A lista é um DOM construído uma vez; mudar de página só troca +`aria-current` e rola até o item com `block: 'nearest'`, apenas quando a seção muda, para não +puxar a lista de volta enquanto o usuário a explora. Depois de clicar num item, PDF.js foca o +texto da página de destino quando ela é desenhada; isso é dele e não foi alterado. + +As miniaturas (`Reader.thumbnail`) desenham a página inteira, com a rotação do leitor, em +130 px de largura (até o dobro de pixels em telas densas), sem tocar nas páginas do viewer. +Um `IntersectionObserver` pede só as páginas perto da área visível; um desenho por vez, o +mais próximo do meio primeiro. Há um teto de 16 canvases vivos (`THUMBNAIL.maxLive`): os mais +antigos fora da vista são liberados (largura e altura zeradas, para devolver a memória na +hora) e os que estão na vista nunca saem. Sair da vista cancela o desenho em andamento, e +fechar o livro, trocar de aba ou esconder a barra cancela tudo (`Reader.destroy` também). Depois +de cada desenho, `page.cleanup()` libera o que a página decodificou, como imagens grandes. +Cada botão nasce com um espaço na proporção da primeira página, a mesma que PDF.js assume, e +toma a forma real quando a página é desenhada; num PDF com páginas de tamanhos diferentes a +lista pode se deslocar um pouco. O Kindle recorta as páginas na área de exibição, mas a miniatura +usa o retângulo original (`fullViewport`). A lista tem um botão por página, sem virtualização +do DOM: é leve até alguns milhares de páginas, e só os desenhos são limitados. + +O estado da barra (aberta, aba e largura) é uma preferência global, normalizada, e vale para +qualquer livro. A janela só limita o que é mostrado: a largura é `min(--sidebar-width, 40vw)` +em CSS, então encolher a janela não regrava a preferência e a largura escolhida volta numa +janela maior. Arrastar ou usar as setas na alça apenas mostra a largura; ela é gravada ao +soltar. O `ResizeObserver` do viewer e a chamada final a `reader.resize()` refazem o zoom +"Largura da página", e PDF.js mantém a posição pelas coordenadas do documento, o que vale +para a rolagem, as duas páginas e o Kindle. A tecla S e os atalhos da barra não disparam ao +digitar, e as setas da alça, das abas, do sumário e das miniaturas não viram páginas. + +**Menus e painéis flutuantes.** `#popover` (`popover()` em `src/ui/overlays.ts`) serve ao +menu de opções do documento e ao painel Aparência. A posição vem de +`placePopover` (`src/ui/popover-position.ts`), uma função sem DOM: abre abaixo do âncora quando +cabe, acima quando não cabe, e fica sempre a 8 px das bordas da janela. Se nenhum lado comporta +o painel, ele usa o lado maior com `max-height` e rolagem interna, e só sem espaço útil em +nenhum lado (menos de 120 px) cobre o âncora. O painel é medido com a altura natural e +posicionado antes de aparecer. Rolar o contêiner do âncora ou redimensionar a janela fecha o +painel, que não é reposicionado. Rolar dentro dele não fecha. + +Com `menu: true` o painel recebe `role="menu"`, os botões viram `menuitem` com `tabindex="-1"`, +e o botão de origem ganha `aria-haspopup="menu"` e `aria-expanded`. O foco vai ao primeiro item. +`src/ui/menu-navigation.ts` (também sem DOM) decide o destino de setas, Home, End e letras, e +pula itens desabilitados. Tab fecha o menu e devolve o foco ao dono antes de seguir, e Esc, tratado +em `shortcuts.ts`, fecha o menu antes da busca e da tela cheia. As teclas de leitura (setas, K, L, +S, R) não agem com o foco num menu. O dono do foco fica em `dataset.owner` (id); o menu do +cartão pode ter como dono o controle que estava em foco, diferente do botão que o ancora. O +painel Aparência é `role="dialog"`, com o foco no papel em uso; Tab sai dele nas pontas e as setas +nos botões de papel andam entre os três. + +O menu do cartão (`bookMenu` em `main.ts`) é o mesmo para o botão "⋯", o botão direito (no +ponteiro), Shift + F10 e a tecla Menu (junto ao cartão) e o botão do leitor; no leitor os itens vêm +em outra ordem (Ler do início, Girar página, Meta, Renomear, Etiquetas, exportações, Preferências, +Guia e Remover) e "Marcar como concluído" sai, porque o cabeçalho tem Concluir leitura. Nos Removidos +oferece Restaurar e Excluir definitivamente. O menu do sistema do WebView é suprimido sobre os +cartões e o painel. + +**Biblioteca que escala.** O catálogo (`library.json`) ganhou `tags` em cada livro (`#[serde(default)]`; +validadas em `normalize_tags`, que apara espaços, descarta vazias e repetidas ignorando maiúsculas e +acentos, e recusa mais de 20 etiquetas ou uma com mais de 40 caracteres) e a lista `archived`, de +registros mínimos (`id`, `title`, `author`, `readingDays`, `goalHistory`) dos livros excluídos +definitivamente. `Snapshot` devolve `archived`, e `history()` em `main.ts` o soma aos livros e aos +Removidos (`src/library/archive.ts` o põe no formato de um livro). A importação compara o hash só +com a biblioteca e Removidos, nunca com `archived`. + +Os comandos em lote são `update_books` (uma lista de `{id, patch}` com o mesmo `Patch` de +`update_book`, validada inteira antes de gravar), `remove_books`, `restore_books` e `purge_books`. +Todos copiam o catálogo, alteram a cópia e gravam uma só vez, de forma atômica (arquivo temporário e +backup); se um item falhar, nada é gravado. `purge_books` só aceita livros de Removidos: grava o +catálogo sem eles (e com os registros em `archived`), depois apaga `pdfs/.pdf` e +`covers/.png` e devolve `{purged, freed, failed}`. Um arquivo que não pôde ser apagado vai em +`failed`, sem desfazer o catálogo; um que já não existia é ignorado. Só nomes simples de arquivo são +apagados. + +No cliente, `src/library/` tem a lógica pura: `tags.ts` (chaves, limites, contagens, sugestões), +`search.ts` (onde um livro foi encontrado: `book` ou só `notes`) e `selection.ts` (intervalos e +poda da seleção). `library-view.ts` desenha a lista, as etiquetas da barra lateral (redesenhadas só +quando mudam, para o foco não se perder) e `updateSelection`, que atualiza as caixas dos cartões e a +barra de ações sem redesenhar a lista. A seleção (um `Set` de ids em `main.ts`) é podada a cada +desenho para o que está na tela, e é limpa ao trocar de filtro e ao abrir um livro. Ctrl + A e Esc +ficam em `shortcuts.ts`. Os diálogos são `tags-dialog.ts` e `purge-dialog.ts`. + +Os cartões seguem o roving tabindex do WAI-ARIA: todos os botões nascem com `tabindex="-1"`, e +`activateCard` (`library-view.ts`) dá 0 aos do cartão ativo (abrir, favorito e opções; em Removidos, +restaurar e opções), que é o que recebeu o foco por último (`focusin` na grade) ou, depois de um +desenho, o mesmo cartão se ele continua na tela e senão o primeiro. A caixa de seleção fica fora da +ordem de Tab. `grid.onkeydown` leva as setas, Home e End a `cardAfterKey` +(`src/library/card-navigation.ts`, sem DOM: recebe as caixas dos cartões e escolhe pela posição, não +pela contagem de colunas) e trata o Espaço no botão principal como a caixa do cartão. O calendário do +histórico usa o mesmo padrão, com `calendarStep` (`src/reading/history.ts`) decidindo o dia de +cada tecla e recusando dias futuros e meses sem registro. + +**Importação pronta.** `src/library/queue.ts` processa um PDF por vez e para quando um livro é aberto +(o trabalho em curso é cancelado e volta ao início da fila; retomar espera 300 ms). Cada livro é +tentado uma vez por sessão: senha ou arquivo inválido são ignorados em silêncio. `enrich.ts` abre +a cópia com `openPdfSource`, desenha a página 1 com cerca de 240 px, grava a capa (`save_cover`) +e o resto (`update_book`) e libera o documento. A regra do título e do autor é pura +(`src/library/metadata.ts`): o título do PDF só vale se o do livro ainda for o nome do arquivo +sem extensão e se for plausível. O cartão é atualizado no lugar (`updateBookCard`), sem perder +a rolagem nem o foco; a lista inteira só é redesenhada se o novo texto muda a ordem por título +ou o resultado de uma busca. + +**Avisos e erros.** `toast()` (`src/ui/toast-stack.ts`, reexportada por `overlays.ts`) empilha +até três avisos em `#toast`, a região `aria-live="polite"` que fica sempre no DOM. Cada aviso +tem o próprio relógio, pausado pelo ponteiro ou pelo foco; a ação vem antes do botão +`.toast-close`. `fail()` usa `describeError` (`src/ui/error-text.ts`, sem DOM): o texto técnico +do Rust, do Windows (`os error N`) ou do PDF.js vira uma frase em português, e o original fica +em "Detalhes". Erros usam `role="alert"` e não somem sozinhos. Ao abrir um diálogo (`modal()`, fora de um +redesenho do mesmo diálogo), `dismissNotices()` dispensa os avisos informativos que estavam na tela, +para que não cubram os botões dele; os erros ficam. O aviso que chega com o diálogo aberto aparece no +topo dele (`#toast` vira filho do diálogo, e `--toast-top` o põe logo abaixo da borda superior, +medida de novo quando o diálogo ou a janela mudam de tamanho). O andamento da importação vai +para o botão `#import-main` (`src/ui/import-progress.ts`), sem um aviso por arquivo. As dicas +mostradas (`hints` nas preferências, nomes em `src/hints.ts`) não voltam. + ## Desenvolvimento assistido O projeto foi desenvolvido com assistência de IA para implementação, refatoração e testes, a partir de necessidades reais de leitura e feedback sobre o uso. As decisões acima, os limites e os resultados de teste são documentados para permitir revisão. A existência de testes não equivale a uma auditoria independente. + +**Notas e realces no texto.** `Note` ganhou `rects` (uma lista de `[x1, y1, x2, y2]` em unidades do PDF, +uma linha de texto cada) e `color` (`yellow`, `green`, `blue` ou `pink`), ambos opcionais e omitidos +na gravação das notas antigas. `notes::normalize` (`src-tauri/src/notes.rs`) valida o conjunto no +`Patch`: números finitos e limitados, no máximo 500 retângulos por nota e 100 000 por documento, cor +do conjunto, e uma nota sem retângulos perde a cor. O texto pode ser vazio num realce. + +No cliente, `reading/selection-capture.ts` lê a seleção página a página: para cada `.page` que a +seleção toca, usa um `Range` limitado à camada de texto dela, junta os retângulos de `getClientRects()` +por linha (`mergeLineRects`, em `reading/note-rects.ts`) e os converte para o PDF com +`viewport.convertToPdfPoint`, de modo que o mesmo realce vale em qualquer zoom, rotação ou modo. A +junção de margens do Kindle (`joined-pages.ts`) só troca o `viewBox` da página, e `convertToPdfPoint` +e `convertToViewportRectangle` já o consideram: o realce usa as coordenadas do documento e o que fica +numa margem recortada simplesmente cai fora da camada (`overflow: clip`). `reading/note-layer.ts` desenha +uma camada `.note-layer` por página, como último filho do `.page`, em porcentagens da página, e a +refaz em `pagerendered`, `scalechanging`, `rotationchanging` e depois de `setMode` e `relayout`, já +que o PDF.js apaga o que não conhece ao redesenhar. A camada ignora o ponteiro; o clique num realce +é encontrado pela posição (`getBoundingClientRect` das marcas), e só o marcador das notas com texto é +um botão. A mistura é `multiply` nos papéis claro e sépia e normal, com cores translúcidas, no escuro, +que inverte as cores do canvas. `Reader.jumpToNote` leva ao realce com `scrollPageIntoView` e entra no +histórico de saltos (`leave` e `arrive`). Shift + setas estendem a seleção (`reading/selection-keys.ts`, com `Selection.modify`; o foco +sempre fica numa camada `.textLayer`, e um passo que cairia fora dela, como na camada dos links entre +duas páginas, segue até o próximo texto ou é desfeito), e `Reader.extendSelection` mantém o fim dela +à vista (rolagem; no Kindle, `turn`). A barra flutuante é `ui/selection-bar.ts` (posicionada por +`placePopover`), o menu é `ui/note-menu.ts` (o `popover` com `menu: true`) e a aba é `ui/notes-panel.ts`. + +`export_notes(id, destination, offsetMinutes?)` gera o Markdown com `notes::markdown` (mesma ordem de +leitura de `noteOrder`), exige `.md` e grava com `tempfile` na pasta do destino seguido de `persist`. +O fuso vem do cliente porque o catálogo guarda só instantes. diff --git a/docs/compatibility.md b/docs/compatibility.md index 875a6a2..dc7ee34 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -22,6 +22,48 @@ WebView2 contém as preferências. Nenhum deles entra no pacote de código-fonte Esta preparação mantém a instalação em uso intacta. Não executa um instalador, não renomeia a pasta instalada e não substitui seus atalhos. +## Abrir com e o programa padrão de PDF + +O `tauri.conf.json` declara `.pdf` em `bundle.fileAssociations` (classe `Folio.PDF`, +descrição "Documento PDF", `application/pdf`, papel de visualizador). Sozinha, a +associação do instalador NSIS do Tauri troca o programa de `.pdf` do usuário: ela grava +o valor padrão de `HKCU\Software\Classes\.pdf` e guarda o anterior em `Folio.PDF_backup`. +Sem uma escolha sua nas Configurações do Windows, isso abriria todos os PDFs no Fólio. +Por isso `src-tauri/windows/installer-hooks.nsh` (`bundle.windows.nsis.installerHooks`) +desfaz essa troca: + +| Momento | O que o instalador faz | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Instalar | Mantém a classe `Folio.PDF` (comando `lume-pdf.exe "%1"`), devolve o programa anterior de `.pdf` e inclui `Folio.PDF` em `.pdf\OpenWithProgids`, o que põe o Fólio em **Abrir com** | +| Desinstalar | Apaga a classe, o valor em `OpenWithProgids` e as chaves que ficarem vazias, sem desfazer uma escolha feita depois da instalação | + +Tudo fica em `HKCU` (instalação por usuário). O instalador não grava `UserChoice`, que o +Windows protege e só muda pelo usuário: para abrir os PDFs no Fólio por padrão, use +**Abrir com > Escolher outro aplicativo** ou as Configurações. Se você já escolheu Fólio +e desinstala, o Windows volta a perguntar. Os ganchos compilam com o `makensis` do Tauri, +mas o fluxo (instalar, atualizar, desinstalar, e a lista **Abrir com**) só se comprova em um +instalador gerado e testado em uma máquina de teste. + +Um PDF aberto pelo Explorer chega ao aplicativo como argumento da linha de comando. Na +primeira instância, `main.rs` guarda os caminhos `.pdf` existentes em um estado gerenciado e +o frontend os pega com `take_pending_files` no fim da inicialização. Numa segunda instância, o +plugin de instância única entrega os argumentos e a pasta de trabalho à primeira (os caminhos +relativos usam essa pasta), que guarda os arquivos no mesmo estado e emite `open-files`; o +frontend responde pegando o que está guardado. Assim nada se perde se o aviso chegar antes de a +janela estar pronta, como ao abrir vários arquivos de uma vez no Explorer. + +## Estado da janela + +`tauri-plugin-window-state` guarda tamanho, posição e se estava maximizada em +`%APPDATA%\com.lume.pdf\.window-state.json` (`window-state.development.json` em uma +compilação de desenvolvimento, para não misturar com a instalada). A tela cheia fica de +fora de propósito. A janela é registrada só no Rust, sem permissões em +`capabilities/`. Na primeira execução vale o `center: true`. Se o tamanho ou a posição +guardados não cabem mais em nenhum monitor, a janela abre menor, se preciso, no centro do +monitor mais próximo. O desinstalador remove essa pasta junto com os dados do aplicativo +quando você pede a remoção deles. A identidade de teste usa `appDirectoriesOverride`, que +também leva esse arquivo para a pasta do teste. + ## Validação isolada `npm run test:native` compila uma identidade própria e usa `appDirectoriesOverride` diff --git a/docs/demo.md b/docs/demo.md index ac4efc1..becfca8 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -18,8 +18,9 @@ trechos de duas páginas, mantendo a diagramação do PDF. ## Conforto sem inverter o papel -Brilho e tela quente podem ser combinados com papel claro. Os controles se -encontram nas preferências, sem ocupar permanentemente a área de leitura. +Brilho e tela quente podem ser combinados com papel claro. Os controles estão no +botão Aparência da barra de leitura e nas preferências, sem ocupar permanentemente a +área de leitura. ![Preferências de conforto](media/conforto.png) diff --git a/package-lock.json b/package-lock.json index 65639cb..f909092 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "folio-pdf", - "version": "1.3.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "folio-pdf", - "version": "1.3.0", + "version": "1.8.0", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", diff --git a/package.json b/package.json index c4f8bfa..5b8f64b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "folio-pdf", - "version": "1.3.0", + "version": "1.8.0", "private": true, "type": "module", "scripts": { @@ -11,6 +11,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "node --test tests/unit/*.test.mjs", + "test:ui": "node tests/reader-chrome.mjs && node tests/goals.mjs && node tests/history.mjs && node tests/progress.mjs && node tests/spread.mjs && node tests/sidebar.mjs", "test:native": "node scripts/test-native.mjs", "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml --locked", "check:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets -- -D warnings", diff --git a/scripts/fixtures.py b/scripts/fixtures.py index 1fb6963..02cbfbc 100644 --- a/scripts/fixtures.py +++ b/scripts/fixtures.py @@ -60,3 +60,48 @@ def create(name, pages): (FIXTURES / "Invalido.pdf").write_bytes(b"not a pdf") (FIXTURES / "Corrompido.pdf").write_bytes(b"%PDF-1.7\nnot a valid document structure") print("PDFs artificiais criados:", FIXTURES) + +# Large internal margins reproduce the visible break between book pages. +joined = canvas.Canvas(str(FIXTURES / "Paginas com margens.pdf"), pagesize=(595, 842)) +for number in range(1, 7): + if number != 5: + joined.setFont("Times-Roman", 17) + for line in range(15): + joined.drawString(65, 650 - line * 28, f"Pagina {number:02d}, linha {line + 1:02d}: texto preservado.") + if number == 3: + joined.setFont("Helvetica", 8) + joined.drawString(65, 18, "RODAPE QUE NAO PODE SER CORTADO") + if number == 4: + joined.setFillColor(HexColor("#356f90")) + joined.rect(70, 25, 120, 48, fill=1, stroke=0) + joined.setFillColor(HexColor("#000000")) + if number == 6: + joined.setFont("Helvetica", 10) + joined.setFillColor(HexColor("#fefefe")) + joined.drawString(65, 20, "TEXTO MUITO CLARO PRESERVADO") + joined.showPage() +joined.save() + +# Chapter starts: outline metadata, explicit heading without an outline, and a +# short final chapter. The preceding lines must all remain readable. +chapters = canvas.Canvas(str(FIXTURES / "Capitulos.pdf"), pagesize=(595, 842)) +for number in range(1, 7): + chapters.setFillColor(HexColor("#000000")) + start = 735 + if number == 2: + chapters.bookmarkPage("chapter-two") + chapters.addOutlineEntry("Uma nova viagem", "chapter-two", 0) + chapters.setFont("Times-Bold", 25) + chapters.drawString(65, 735, "Uma nova viagem") + start = 670 + if number in [4, 6]: + chapters.setFont("Times-Bold", 21) + chapters.drawString(65, 735, "CAPITULO DOIS" if number == 4 else "CAPITULO TRES") + start = 670 + chapters.setFont("Times-Roman", 15) + count = 3 if number == 6 else 24 + for line in range(count): + prefix = "ANTERIOR" if number == 1 else f"PAGINA {number}" + chapters.drawString(65, start - line * 23, f"{prefix} LINHA {line + 1:02d} - conteudo preservado.") + chapters.showPage() +chapters.save() diff --git a/scripts/package-source.mjs b/scripts/package-source.mjs index 708ee83..0df9085 100644 --- a/scripts/package-source.mjs +++ b/scripts/package-source.mjs @@ -41,6 +41,7 @@ const roots = [ 'src-tauri/capabilities', 'src-tauri/icons', 'src-tauri/src', + 'src-tauri/windows', ...[ 'assets.mjs', 'notices.mjs', @@ -55,6 +56,14 @@ const roots = [ 'brightness.mjs', 'kindle.mjs', 'kindle-restart.mjs', + 'joined-pages.mjs', + 'chapters.mjs', + 'reader-chrome.mjs', + 'goals.mjs', + 'history.mjs', + 'progress.mjs', + 'spread.mjs', + 'sidebar.mjs', 'demo.mjs', 'support', 'unit', diff --git a/scripts/test-native.mjs b/scripts/test-native.mjs index cb2d686..ce4ccbb 100644 --- a/scripts/test-native.mjs +++ b/scripts/test-native.mjs @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import path from 'node:path'; @@ -20,6 +20,9 @@ await new Promise((resolve, reject) => ); const port = server.address().port; await new Promise((resolve) => server.close(resolve)); +// The DevTools port goes into the test build's own window options. Some WebView2 installations +// (the GitHub Windows runner, for one) ignore WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS, and setting +// the options replaces Tauri's default arguments, so those are kept here too. const { app: appConfig } = JSON.parse(await readFile('src-tauri/tauri.conf.json', 'utf8')); const config = path.join(runRoot, 'tauri.test.conf.json'); await writeFile( @@ -57,23 +60,65 @@ async function launch() { windowsHide: true, env: { ...env, WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${port}` }, }); + // Kept for the error below: a panic or a WebView2 failure is told on stderr. let stderr = ''; let launchError; app.stderr.on('data', (chunk) => (stderr = (stderr + chunk).slice(-4000))); + // An executable that cannot start at all (missing build, blocked) says so here. app.once('error', (error) => (launchError = error)); - for (let attempt = 0; attempt < 120; attempt++) { + // The first WebView2 start on a fresh CI machine can take far longer than on a desktop. + let last = 'no answer yet'; + for (let attempt = 0; attempt < 480; attempt++) { if (launchError) throw launchError; if (app.exitCode !== null) throw new Error(`Isolated app exited before becoming ready (${app.exitCode}).\n${stderr}`); try { const response = await fetch(`${env.FOLIO_CDP_URL}/json/list`); - if ((await response.json()).some((tab) => tab.url?.includes('tauri.localhost'))) return; - } catch { + const tabs = await response.json(); + if (tabs.some((tab) => tab.url?.includes('tauri.localhost'))) return; + last = `pages: ${tabs.map((tab) => tab.url).join(', ') || 'none'}`; + } catch (error) { /* WebView2 is still starting. */ + last = String(error?.cause?.code || error); } await delay(250); } - throw new Error(`Timed out starting the isolated WebView2.\n${stderr}`); + throw new Error( + `Timed out starting the isolated WebView2 (last: ${last}).\n${stderr}\n${launchReport()}`, + ); +} +// What the machine shows when WebView2 never answers: its processes, the app's windows, the +// files WebView2 left in the isolated profile, and a picture of the screen for the CI evidence. +function launchReport() { + const shell = (script) => { + try { + return execFileSync('powershell', ['-NoProfile', '-Command', script], { + encoding: 'utf8', + timeout: 30000, + }).trim(); + } catch (error) { + return `(${String(error).split('\n')[0]})`; + } + }; + const shot = path.join(root, 'test-results', 'native-launch-timeout.png').replaceAll("'", "''"); + return [ + 'WebView2 processes:', + shell( + 'Get-CimInstance Win32_Process -Filter "Name=\'msedgewebview2.exe\'" | Select-Object -First 3 -ExpandProperty CommandLine', + ), + 'App windows:', + shell( + `Get-Process -Id ${app.pid} | Format-List Responding,MainWindowTitle,MainWindowHandle,SessionId | Out-String`, + ), + 'Profile files:', + shell( + `Get-ChildItem -Recurse -Depth 2 '${runRoot.replaceAll("'", "''")}\\data' | Select-Object -First 25 -ExpandProperty FullName`, + ), + 'Screen:', + shell( + `Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $p = New-Object System.Drawing.Bitmap $b.Width, $b.Height; [System.Drawing.Graphics]::FromImage($p).CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size); $p.Save('${shot}'); "saved $($b.Width)x$($b.Height)"`, + ), + ].join('\n'); } async function waitClosed() { for (let i = 0; i < 80 && app.exitCode === null; i++) await delay(100); @@ -92,7 +137,8 @@ try { config, ); await launch(); - for (const file of ['e2e', 'position', 'brightness', 'kindle']) await node(`tests/${file}.mjs`); + for (const file of ['e2e', 'position', 'brightness', 'kindle', 'joined-pages', 'chapters']) + await node(`tests/${file}.mjs`); await node('tests/kindle-restart.mjs', 'prepare'); await waitClosed(); await launch(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ae0cc9e..680bd2a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1968,7 +1968,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lume-pdf" -version = "1.3.0" +version = "1.8.0" dependencies = [ "serde", "serde_json", @@ -1977,6 +1977,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-single-instance", + "tauri-plugin-window-state", "tempfile", "uuid", ] @@ -3568,6 +3569,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3" +dependencies = [ + "bitflags 2.13.2", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-runtime" version = "2.12.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6031c7b..dbdcb92 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lume-pdf" -version = "1.3.0" +version = "1.8.0" description = "Leitor de PDF com biblioteca local" authors = ["Fólio contributors"] license = "MIT" @@ -13,6 +13,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-dialog = "2" tauri-plugin-single-instance = "2" +tauri-plugin-window-state = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0bb50cf..934425d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,8 @@ -use crate::library::{Book, Library, Patch, Result}; +use crate::launch::PendingFiles; +use crate::library::{ + expand_import_paths, Archived, Book, BookUpdate, Library, Patch, PurgeResult, Result, +}; +use crate::notes; use std::{ path::PathBuf, sync::{Arc, Mutex}, @@ -10,25 +14,41 @@ pub type Store = Arc>; pub struct Snapshot { books: Vec, trash: Vec, + // What stays of the documents deleted for good: only the reading history, never listed. + archived: Vec, folder: String, warning: Option, } #[tauri::command] -pub fn library_list(state: tauri::State) -> Result { - let l = state.lock().map_err(|e| e.to_string())?; +pub fn library_list(state: tauri::State, history_day: Option) -> Result { + let mut l = state.lock().map_err(|e| e.to_string())?; + if let Some(day) = history_day { + l.init_history(&day)?; + } Ok(Snapshot { books: l.catalog.books.clone(), trash: l.catalog.trash.clone(), + archived: l.catalog.archived.clone(), folder: l.root.to_string_lossy().into(), warning: l.warning.clone(), }) } +// A document the import found already in the library: enough to say which one it was and to +// find its card. +#[derive(serde::Serialize)] +pub struct ExistingBook { + id: String, + title: String, +} #[derive(serde::Serialize)] pub struct ImportResult { added: Vec, duplicates: u32, + existing: Vec, errors: Vec, } +// Folders among `paths` are expanded to the PDFs inside them first, so the progress counts +// files, not what was chosen. #[tauri::command] pub async fn import_pdfs( paths: Vec, @@ -40,26 +60,33 @@ pub async fn import_pdfs( let mut out = ImportResult { added: vec![], duplicates: 0, + existing: vec![], errors: vec![], }; - let total = paths.len(); - for (i, path) in paths.iter().enumerate() { - let result = store - .lock() - .map_err(|e| e.to_string())? - .import(&PathBuf::from(path)); + let chosen: Vec = paths.iter().map(PathBuf::from).collect(); + let (files, problems) = expand_import_paths(&chosen); + out.errors = problems; + let total = files.len(); + if total > 0 { + let _ = app.emit( + "import-progress", + serde_json::json!({"done":0,"total":total}), + ); + } + for (i, path) in files.iter().enumerate() { + let result = store.lock().map_err(|e| e.to_string())?.import(path); match result { Ok((b, true)) => { - let _ = b; - out.duplicates += 1 + out.duplicates += 1; + out.existing.push(ExistingBook { + id: b.id, + title: b.title, + }); } Ok((b, false)) => out.added.push(b), Err(e) => out.errors.push(format!( "{}: {e}", - PathBuf::from(path) - .file_name() - .unwrap_or_default() - .to_string_lossy() + path.file_name().unwrap_or_default().to_string_lossy() )), }; let _ = app.emit( @@ -72,10 +99,43 @@ pub async fn import_pdfs( .await .map_err(|e| e.to_string())? } +// The PDFs the app was opened with (see `launch`), handed over once: a second call gets nothing +// until another launch brings more. +#[tauri::command] +pub fn take_pending_files(state: tauri::State) -> Result> { + state.take() +} #[tauri::command] pub fn update_book(id: String, patch: Patch, state: tauri::State) -> Result { state.lock().map_err(|e| e.to_string())?.update(&id, patch) } +// Several documents in one write of the catalog: all of them change, or none does. +#[tauri::command] +pub fn update_books(updates: Vec, state: tauri::State) -> Result> { + state + .lock() + .map_err(|e| e.to_string())? + .update_many(updates) +} +#[tauri::command] +pub fn remove_books(ids: Vec, state: tauri::State) -> Result<()> { + state.lock().map_err(|e| e.to_string())?.remove_many(&ids) +} +#[tauri::command] +pub fn restore_books(ids: Vec, state: tauri::State) -> Result<()> { + state.lock().map_err(|e| e.to_string())?.restore_many(&ids) +} +// Deletes documents of Removidos for good (see `Library::purge`). The files are deleted away from +// the window's thread. +#[tauri::command] +pub async fn purge_books(ids: Vec, state: tauri::State<'_, Store>) -> Result { + let store = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + store.lock().map_err(|e| e.to_string())?.purge(&ids) + }) + .await + .map_err(|e| e.to_string())? +} #[tauri::command] pub fn remove_book(id: String, state: tauri::State) -> Result<()> { state.lock().map_err(|e| e.to_string())?.remove(&id) @@ -94,7 +154,7 @@ pub fn pdf_path(id: String, state: tauri::State) -> Result { .into()) } #[tauri::command] -pub fn save_cover(id: String, bytes: Vec, state: tauri::State) -> Result<()> { +pub fn save_cover(id: String, bytes: Vec, state: tauri::State) -> Result { let mut l = state.lock().map_err(|e| e.to_string())?; l.path(&id)?; if bytes.len() > 500_000 || !bytes.starts_with(&[137, 80, 78, 71]) { @@ -105,8 +165,8 @@ pub fn save_cover(id: String, bytes: Vec, state: tauri::State) -> Res if let Some(b) = l.catalog.books.iter_mut().find(|b| b.id == id) { b.cover = true; } - l.update(&id, Patch::default())?; - Ok(()) + // The book as it is now, so that a card can be drawn again without listing the library. + l.update(&id, Patch::default()) } #[tauri::command] pub fn open_folder(state: tauri::State) -> Result<()> { @@ -117,6 +177,19 @@ pub fn open_folder(state: tauri::State) -> Result<()> { .map_err(|e| e.to_string())?; Ok(()) } +// The Explorer on the folder of a file, with the file chosen: where an export was saved. +#[tauri::command] +pub fn reveal_file(path: String) -> Result<()> { + let path = PathBuf::from(path); + // A file that is gone says so, in the words of the system, instead of opening some other folder. + std::fs::metadata(&path).map_err(|e| e.to_string())?; + std::process::Command::new("explorer.exe") + .arg("/select,") + .arg(&path) + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} #[tauri::command] pub async fn export_pdf( id: String, @@ -135,3 +208,31 @@ pub async fn export_pdf( .await .map_err(|e| e.to_string())? } +// The notes of a document as a Markdown file (see `notes::markdown`), written all at once. +// `offset_minutes` is the reader's distance from UTC, for the dates. +#[tauri::command] +pub async fn export_notes( + id: String, + destination: String, + offset_minutes: Option, + state: tauri::State<'_, Store>, +) -> Result<()> { + let text = { + let l = state.lock().map_err(|e| e.to_string())?; + let book = l + .catalog + .books + .iter() + .find(|b| b.id == id) + .ok_or("PDF não encontrado")?; + if book.notes.is_empty() { + return Err("Este documento ainda não tem notas para exportar.".into()); + } + notes::markdown(book, offset_minutes.unwrap_or(0)) + }; + tauri::async_runtime::spawn_blocking(move || { + notes::write_markdown(&PathBuf::from(destination), &text) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs new file mode 100644 index 0000000..73243e6 --- /dev/null +++ b/src-tauri/src/launch.rs @@ -0,0 +1,84 @@ +use crate::library::{is_pdf_name, Result}; +use std::{path::Path, sync::Mutex}; +use tauri::{AppHandle, Emitter, Manager}; +// The PDFs the app was launched with: a double click or "Abrir com" in the Explorer, or a second +// launch that handed its arguments to this one. They wait here, and not only in an event, because +// the window may still be starting when they arrive: it takes them when it is ready. +#[derive(Default)] +pub struct PendingFiles(Mutex>); +impl PendingFiles { + pub fn push(&self, paths: Vec) { + if let Ok(mut waiting) = self.0.lock() { + for path in paths { + if !waiting.contains(&path) { + waiting.push(path); + } + } + } + } + // What is waiting, once: whoever takes it leaves the list empty. + pub fn take(&self) -> Result> { + let mut waiting = self.0.lock().map_err(|e| e.to_string())?; + Ok(std::mem::take(&mut *waiting)) + } +} +// The PDFs among the arguments of a launch (without the executable). A relative path is the one of +// the process that was launched, so `cwd` is its folder, not necessarily this process's. What is +// not an existing file with the .pdf extension is left out, and so is a path that came twice. +pub fn pdf_args(args: I, cwd: &Path) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + let mut found: Vec = vec![]; + for arg in args { + // An absolute path stays as it is; `join` only completes the relative ones. + let path = cwd.join(arg.as_ref()); + if !is_pdf_name(&path) || !path.is_file() { + continue; + } + let path = path.to_string_lossy().into_owned(); + if !found.contains(&path) { + found.push(path); + } + } + found +} +// A second launch of the app ends at once and sends its arguments here. The PDFs among them wait +// in `PendingFiles`, and the window is told that there is something to take. +pub fn receive(app: &AppHandle, args: &[String], cwd: &str) { + let paths = pdf_args(args.iter().skip(1), Path::new(cwd)); + if paths.is_empty() { + return; + } + app.state::().push(paths); + let _ = app.emit("open-files", ()); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn launch_arguments_keep_existing_pdfs_once() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join("livro.PDF"), b"%PDF-1.4\n").unwrap(); + std::fs::write(d.path().join("texto.txt"), b"x").unwrap(); + let absolute = d.path().join("livro.PDF"); + let found = pdf_args( + [ + "livro.PDF", + absolute.to_str().unwrap(), + "texto.txt", + "ausente.pdf", + "--flag", + ], + d.path(), + ); + assert_eq!(found, [absolute.to_string_lossy().into_owned()]); + let pending = PendingFiles::default(); + pending.push(found.clone()); + pending.push(found.clone()); + assert_eq!(pending.take().unwrap(), found); + assert!(pending.take().unwrap().is_empty()); + } +} diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index d52da6c..606a3fa 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -1,13 +1,25 @@ +use crate::notes::normalize as normalize_notes; +use crate::reading::{valid_day, validate_days, BookGoal, GoalHistory, ReadingDays}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ + collections::HashSet, fs, io::{Read, Write}, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; pub type Result = std::result::Result; +// The longest title or author, in characters, and the most pages a document may report. +const MAX_TEXT: usize = 500; +const MAX_PAGES: u32 = 1_000_000; +// The most tags a document may have, and the longest one, in characters. +pub const MAX_TAGS: usize = 20; +pub const MAX_TAG_CHARS: usize = 40; +// How deep an import goes into a folder. Links are never followed, so this only bounds a very +// deep tree. +const MAX_FOLDER_DEPTH: usize = 32; pub fn now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -23,12 +35,24 @@ pub struct Note { pub text: String, pub quote: String, pub created: u64, + // A highlight: one rectangle for each line of the passage, in PDF units and independent of + // zoom and rotation ([x1, y1, x2, y2]), and its colour (see `notes::COLORS`). The text of + // a highlight can be empty. Notes from before have neither, and are saved without them. + #[serde(skip_serializing_if = "Option::is_none")] + pub rects: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase", default)] pub struct Book { + pub goal_history: GoalHistory, + pub reading_goal: BookGoal, + pub reading_days: ReadingDays, pub id: String, pub title: String, + // Read from the PDF's own metadata by the library queue; empty when it has none. + pub author: String, pub original_name: String, pub hash: String, pub size: u64, @@ -45,12 +69,18 @@ pub struct Book { pub bookmarks: Vec, pub notes: Vec, pub cover: bool, + // Free labels of the reader, in the order they were added. See `normalize_tags`. + pub tags: Vec, } impl Default for Book { fn default() -> Self { Self { + goal_history: GoalHistory::new(), + reading_goal: BookGoal::default(), + reading_days: ReadingDays::new(), id: String::new(), title: String::new(), + author: String::new(), original_name: String::new(), hash: String::new(), size: 0, @@ -67,6 +97,29 @@ impl Default for Book { bookmarks: vec![], notes: vec![], cover: false, + tags: vec![], + } + } +} +// What is left of a document that was deleted for good: no file, no hash, only what the reading +// history needs to go on counting its days. It is not in the library and not in Removidos. +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +#[serde(rename_all = "camelCase", default)] +pub struct Archived { + pub id: String, + pub title: String, + pub author: String, + pub reading_days: ReadingDays, + pub goal_history: GoalHistory, +} +impl From<&Book> for Archived { + fn from(book: &Book) -> Self { + Self { + id: book.id.clone(), + title: book.title.clone(), + author: book.author.clone(), + reading_days: book.reading_days.clone(), + goal_history: book.goal_history.clone(), } } } @@ -76,11 +129,16 @@ pub struct Catalog { pub version: u32, pub books: Vec, pub trash: Vec, + pub archived: Vec, } #[derive(Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct Patch { + pub goal_day: Option, + pub reading_goal: Option, + pub reading_days: Option, pub title: Option, + pub author: Option, pub last_opened: Option, pub page: Option, pub pages: Option, @@ -92,6 +150,225 @@ pub struct Patch { pub finished: Option, pub bookmarks: Option>, pub notes: Option>, + pub tags: Option>, +} +// One entry of a batch: the same patch `update` takes, for one document. +#[derive(Deserialize)] +pub struct BookUpdate { + pub id: String, + pub patch: Patch, +} +// What `purge` did. The catalog no longer has the documents; `failed` has one line for each file +// that could not be deleted (the copy stays in the folder, and the user can delete it by hand). +#[derive(Serialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct PurgeResult { + pub purged: u32, + // The bytes of the PDF copies that were deleted. + pub freed: u64, + pub failed: Vec, +} +// Two spellings of a tag are the same tag when they differ only in case or accents. +fn fold_accent(c: char) -> char { + match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => 'a', + 'ç' | 'ć' | 'ĉ' | 'ċ' | 'č' => 'c', + 'ď' => 'd', + 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => 'e', + 'ĝ' | 'ğ' | 'ġ' | 'ģ' => 'g', + 'ĥ' => 'h', + 'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' => 'i', + 'ĵ' => 'j', + 'ķ' => 'k', + 'ĺ' | 'ļ' | 'ľ' => 'l', + 'ñ' | 'ń' | 'ņ' | 'ň' => 'n', + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ō' | 'ŏ' | 'ő' => 'o', + 'ŕ' | 'ŗ' | 'ř' => 'r', + 'ś' | 'ŝ' | 'ş' | 'š' => 's', + 'ţ' | 'ť' => 't', + 'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => 'u', + 'ŵ' => 'w', + 'ý' | 'ÿ' | 'ŷ' => 'y', + 'ź' | 'ż' | 'ž' => 'z', + other => other, + } +} +pub fn tag_key(tag: &str) -> String { + tag.chars() + .flat_map(char::to_lowercase) + // Combining accents, for a tag typed in the decomposed form. + .filter(|c| !('\u{300}'..='\u{36f}').contains(c)) + .map(fold_accent) + .collect() +} +// The tags of a document as they are kept: spaces trimmed (and runs of them made one), the empty +// ones and the repeats (the first spelling stays) dropped. A tag that is too long, or too many of +// them, is refused, and nothing is changed. +pub fn normalize_tags(tags: Vec) -> Result> { + let mut seen = HashSet::new(); + let mut out: Vec = Vec::new(); + for tag in tags { + if tag.chars().any(|c| c.is_control() && !c.is_whitespace()) { + return Err("Use etiquetas sem caracteres de controle.".into()); + } + let tag = tag.split_whitespace().collect::>().join(" "); + if tag.is_empty() { + continue; + } + if tag.chars().count() > MAX_TAG_CHARS { + return Err(format!("Use etiquetas com até {MAX_TAG_CHARS} caracteres.")); + } + if seen.insert(tag_key(&tag)) { + out.push(tag); + if out.len() > MAX_TAGS { + return Err(format!("Use no máximo {MAX_TAGS} etiquetas por documento.")); + } + } + } + Ok(out) +} +// A document id becomes a file name. Only a plain name is deleted: never a path. +fn plain_file_name(id: &str) -> bool { + let mut parts = Path::new(id).components(); + matches!( + (parts.next(), parts.next()), + (Some(Component::Normal(name)), None) if name == id + ) +} +fn unique(ids: &[String]) -> Vec<&String> { + let mut seen = HashSet::new(); + ids.iter().filter(|id| seen.insert(id.as_str())).collect() +} +pub fn is_pdf_name(path: &Path) -> bool { + path.extension() + .map(|v| v.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) +} +// What an import will try, from what the user chose. A file chosen by name stays on the list, +// even when it is not a PDF: importing it says so. A folder becomes the PDFs inside it, found at +// any depth in name order; whatever else is in it is left alone, without a word. Links and +// junctions are neither followed nor listed, so the walk stays inside the folder that was chosen. +// The second list holds the folders that could not be read. +pub fn expand_import_paths(paths: &[PathBuf]) -> (Vec, Vec) { + let mut files = vec![]; + let mut problems = vec![]; + for path in paths { + match fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => { + if let Err(e) = collect_pdfs(path, 0, &mut files) { + problems.push(format!( + "{}: {e}", + path.file_name().unwrap_or_default().to_string_lossy() + )); + } + } + _ => files.push(path.clone()), + } + } + (files, problems) +} +fn collect_pdfs(dir: &Path, depth: usize, out: &mut Vec) -> std::io::Result<()> { + let mut entries: Vec = fs::read_dir(dir)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect(); + entries.sort_by_cached_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase())); + for path in entries { + let Ok(meta) = fs::symlink_metadata(&path) else { + continue; + }; + if meta.file_type().is_symlink() { + continue; + } + if meta.is_dir() { + // A subfolder that cannot be read does not stop the rest. + if depth < MAX_FOLDER_DEPTH { + let _ = collect_pdfs(&path, depth + 1, out); + } + } else if meta.is_file() && is_pdf_name(&path) { + out.push(path); + } + } + Ok(()) +} +// What a patch does to one document. Used by `update` and `update_many`, which write the catalog +// only when it is all valid. +fn apply_patch(book: &mut Book, patch: Patch) -> Result<()> { + if let Some(v) = patch.title { + if v.trim().is_empty() || v.chars().count() > MAX_TEXT { + return Err("Use um título entre 1 e 500 caracteres.".into()); + } + book.title = v.trim().into() + } + if let Some(v) = patch.author { + let v = v.trim(); + if v.chars().count() > MAX_TEXT || v.chars().any(char::is_control) { + return Err("Use um autor com até 500 caracteres, sem quebras de linha.".into()); + } + book.author = v.into() + } + if let Some(v) = patch.last_opened { + book.last_opened = v + } + if let Some(v) = patch.pages { + if v > MAX_PAGES { + return Err("Número de páginas inválido.".into()); + } + book.pages = v + } + if let Some(v) = patch.page { + book.page = v.max(1).min(book.pages.max(1)) + } + if let Some(v) = patch.top { + if v.is_finite() { + book.top = Some(v) + } + } + if let Some(v) = patch.left { + if v.is_finite() { + book.left = Some(v) + } + } + if let Some(v) = patch.zoom { + book.zoom = v + } + if let Some(v) = patch.rotation { + book.rotation = v.rem_euclid(360) + } + if let Some(v) = patch.favorite { + book.favorite = v + } + if let Some(v) = patch.finished { + book.finished = v + } + if let Some(v) = patch.reading_goal { + v.validate()?; + if let Some(day) = patch.goal_day { + if !valid_day(&day) || book.goal_history.len() >= 36500 { + return Err("Data do histórico inválida.".into()); + } + book.goal_history.insert(day, v.clone()); + } else if !book.goal_history.is_empty() { + return Err("Informe a data da alteração da meta.".into()); + } + book.reading_goal = v; + } + if let Some(v) = patch.reading_days { + book.reading_days = validate_days(v, book.pages)?; + } + if let Some(v) = patch.bookmarks { + book.bookmarks = v + .into_iter() + .filter(|p| *p >= 1 && *p <= book.pages) + .collect() + } + if let Some(v) = patch.notes { + book.notes = normalize_notes(v)? + } + if let Some(v) = patch.tags { + book.tags = normalize_tags(v)?; + } + Ok(()) } pub struct Library { pub root: PathBuf, @@ -99,6 +376,25 @@ pub struct Library { pub warning: Option, } impl Library { + pub fn init_history(&mut self, day: &str) -> Result<()> { + if !valid_day(day) { + return Err("Data do histórico inválida.".into()); + } + let mut next = self.catalog.clone(); + let mut changed = false; + for book in next.books.iter_mut().chain(&mut next.trash) { + if book.goal_history.is_empty() { + book.goal_history + .insert(day.into(), book.reading_goal.clone()); + changed = true; + } + } + if changed { + self.save(&next)?; + self.catalog = next; + } + Ok(()) + } pub fn open(root: PathBuf) -> Result { fs::create_dir_all(root.join("pdfs")).map_err(|e| e.to_string())?; fs::create_dir_all(root.join("covers")).map_err(|e| e.to_string())?; @@ -151,11 +447,7 @@ impl Library { Ok(()) } pub fn import(&mut self, path: &Path) -> Result<(Book, bool)> { - if !path - .extension() - .map(|v| v.eq_ignore_ascii_case("pdf")) - .unwrap_or(false) - { + if !is_pdf_name(path) { return Err("Selecione um arquivo .pdf.".into()); } let mut source = fs::File::open(path).map_err(|e| e.to_string())?; @@ -229,87 +521,138 @@ impl Library { .iter_mut() .find(|b| b.id == id) .ok_or("PDF não encontrado")?; - if let Some(v) = patch.title { - if v.trim().is_empty() || v.len() > 500 { - return Err("Use um título entre 1 e 500 caracteres.".into()); - } - book.title = v.trim().into() - } - if let Some(v) = patch.last_opened { - book.last_opened = v - } - if let Some(v) = patch.pages { - book.pages = v - } - if let Some(v) = patch.page { - book.page = v.max(1).min(book.pages.max(1)) - } - if let Some(v) = patch.top { - if v.is_finite() { - book.top = Some(v) - } - } - if let Some(v) = patch.left { - if v.is_finite() { - book.left = Some(v) - } - } - if let Some(v) = patch.zoom { - book.zoom = v - } - if let Some(v) = patch.rotation { - book.rotation = v.rem_euclid(360) - } - if let Some(v) = patch.favorite { - book.favorite = v - } - if let Some(v) = patch.finished { - book.finished = v - } - if let Some(v) = patch.bookmarks { - book.bookmarks = v - .into_iter() - .filter(|p| *p >= 1 && *p <= book.pages) - .collect() - } - if let Some(v) = patch.notes { - if v.len() > 10000 - || v.iter() - .any(|n| n.text.len() > 20000 || n.quote.len() > 20000) - { - return Err("Limite de notas excedido.".into()); - } - book.notes = v - } + apply_patch(book, patch)?; let result = book.clone(); self.save(&next)?; self.catalog = next; Ok(result) } - pub fn remove(&mut self, id: &str) -> Result<()> { + // Several documents, one save. Every patch is checked before anything is written: if one of + // them is refused, or a document is not found, the catalog stays exactly as it was. Gives the + // documents as they are now, in the order of the library. + pub fn update_many(&mut self, updates: Vec) -> Result> { + if updates.is_empty() { + return Ok(vec![]); + } let mut next = self.catalog.clone(); - let i = next + let mut ids = HashSet::new(); + for BookUpdate { id, patch } in updates { + let book = next + .books + .iter_mut() + .find(|b| b.id == id) + .ok_or("PDF não encontrado")?; + apply_patch(book, patch)?; + ids.insert(id); + } + let result = next .books .iter() - .position(|b| b.id == id) - .ok_or("PDF não encontrado")?; - next.trash.push(next.books.remove(i)); + .filter(|b| ids.contains(&b.id)) + .cloned() + .collect(); + self.save(&next)?; + self.catalog = next; + Ok(result) + } + pub fn remove(&mut self, id: &str) -> Result<()> { + self.remove_many(&[id.to_string()]) + } + // To Removidos, all or none, with one save. + pub fn remove_many(&mut self, ids: &[String]) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } + let mut next = self.catalog.clone(); + for id in unique(ids) { + let i = next + .books + .iter() + .position(|b| &b.id == id) + .ok_or("PDF não encontrado")?; + next.trash.push(next.books.remove(i)); + } self.save(&next)?; self.catalog = next; Ok(()) } pub fn restore(&mut self, id: &str) -> Result<()> { + self.restore_many(&[id.to_string()]) + } + // Back to the library, all or none, with one save. + pub fn restore_many(&mut self, ids: &[String]) -> Result<()> { + if ids.is_empty() { + return Ok(()); + } let mut next = self.catalog.clone(); - let i = next - .trash - .iter() - .position(|b| b.id == id) - .ok_or("PDF não encontrado")?; - next.books.push(next.trash.remove(i)); + for id in unique(ids) { + let i = next + .trash + .iter() + .position(|b| &b.id == id) + .ok_or("PDF não encontrado")?; + next.books.push(next.trash.remove(i)); + } self.save(&next)?; self.catalog = next; Ok(()) } + // Deletes documents of Removidos for good. The catalog is written first, without them (and with + // the minimal record of each in `archived`, which keeps their days in the reading history); + // only then are `pdfs/.pdf` and `covers/.png` deleted. A file that cannot be deleted + // does not undo the catalog: it is told in `failed`, and the file stays where it is. A document + // that is not in Removidos is never touched, and then nothing is deleted at all. + pub fn purge(&mut self, ids: &[String]) -> Result { + if ids.is_empty() { + return Ok(PurgeResult::default()); + } + let mut next = self.catalog.clone(); + let mut gone: Vec = vec![]; + for id in unique(ids) { + let i = next + .trash + .iter() + .position(|b| &b.id == id) + .ok_or("PDF não encontrado")?; + gone.push(next.trash.remove(i)); + } + for book in &gone { + next.archived.retain(|a| a.id != book.id); + next.archived.push(Archived::from(book)); + } + self.save(&next)?; + self.catalog = next; + let mut result = PurgeResult { + purged: gone.len() as u32, + ..Default::default() + }; + for book in &gone { + if !plain_file_name(&book.id) { + result + .failed + .push(format!("{}: nome de arquivo inválido", book.title)); + continue; + } + let copy = self.root.join("pdfs").join(format!("{}.pdf", book.id)); + match fs::remove_file(©) { + Ok(()) => result.freed += book.size, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => result.failed.push(format!( + "{}: a cópia do PDF não foi excluída ({e})", + book.title + )), + } + let cover = self.root.join("covers").join(format!("{}.png", book.id)); + match fs::remove_file(&cover) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => result + .failed + .push(format!("{}: a capa não foi excluída ({e})", book.title)), + } + } + Ok(result) + } pub fn path(&self, id: &str) -> Result { if !self.catalog.books.iter().any(|b| b.id == id) { return Err("PDF não encontrado".into()); @@ -322,6 +665,147 @@ impl Library { mod tests { use super::*; #[test] + fn historical_goals_are_initialized_once_and_survive_changes_and_restore() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("library"); + let source = d.path().join("book.pdf"); + fs::write(&source, b"%PDF-1.7\nfixture").unwrap(); + let mut l = Library::open(root.clone()).unwrap(); + let (b, _) = l.import(&source).unwrap(); + l.init_history("2026-10-01").unwrap(); + let before = fs::read(root.join("library.json")).unwrap(); + l.init_history("2026-10-02").unwrap(); + assert_eq!(before, fs::read(root.join("library.json")).unwrap()); + let patch:Patch=serde_json::from_value(serde_json::json!({"goalDay":"2026-10-10","readingGoal":{"mode":"custom","unit":"chapters","target":3}})).unwrap(); + l.update(&b.id, patch).unwrap(); + let invalid: Patch = serde_json::from_value( + serde_json::json!({"goalDay":"2026-02-30","readingGoal":{"mode":"none"}}), + ) + .unwrap(); + assert!(l.update(&b.id, invalid).is_err()); + let days = (1..=31) + .map(|n| { + ( + format!("2026-10-{n:02}"), + crate::reading::ReadingDay { + page_adjustment: 8, + ..Default::default() + }, + ) + }) + .chain(std::iter::once(( + "2026-11-01".into(), + crate::reading::ReadingDay::default(), + ))) + .collect(); + l.update( + &b.id, + Patch { + reading_days: Some(days), + ..Default::default() + }, + ) + .unwrap(); + l.remove(&b.id).unwrap(); + drop(l); + let mut l = Library::open(root).unwrap(); + l.restore(&b.id).unwrap(); + let restored = &l.catalog.books[0]; + assert_eq!(restored.goal_history.len(), 2); + assert!(matches!( + restored.goal_history["2026-10-01"].mode, + crate::reading::GoalMode::Inherit + )); + assert_eq!(restored.goal_history["2026-10-10"].target, 3); + assert_eq!(restored.reading_days.len(), 32); + assert_eq!(restored.reading_days["2026-10-01"].page_adjustment, 8); + } + #[test] + fn the_three_goal_modes_stay_valid_in_goals_and_in_their_history() { + // `inherit` and `none` both mean "no goal of its own" now, and `custom` is a goal of its + // own. A catalog saved with any of them opens and is saved again as it was. + let old = serde_json::json!({"id":"old","title":"Livro", + "readingGoal":{"mode":"none","unit":"pages","target":10}, + "goalHistory":{ + "2026-10-01":{"mode":"inherit","unit":"pages","target":10}, + "2026-10-10":{"mode":"custom","unit":"chapters","target":2}, + "2026-10-20":{"mode":"none","unit":"pages","target":10}}}); + let book: Book = serde_json::from_value(old.clone()).unwrap(); + let saved = serde_json::to_value(book).unwrap(); + assert_eq!(saved["readingGoal"], old["readingGoal"]); + assert_eq!(saved["goalHistory"], old["goalHistory"]); + for mode in ["inherit", "custom", "none"] { + let patch: Patch = serde_json::from_value(serde_json::json!({ + "goalDay":"2026-10-21","readingGoal":{"mode":mode,"unit":"pages","target":10}})) + .unwrap(); + assert!(patch.reading_goal.unwrap().validate().is_ok()); + } + } + #[test] + fn legacy_catalog_adds_goals_without_changing_reading_data() { + let old = serde_json::json!({"id":"old", "page":42, "pages":300, + "top":123.5, "left":24.0, "zoom":"page-width", "finished":true, + "bookmarks":[12,42], "title":"Livro existente"}); + let book: Book = serde_json::from_value(old.clone()).unwrap(); + let saved = serde_json::to_value(book).unwrap(); + for (key, value) in old.as_object().unwrap() { + assert_eq!(&saved[key], value); + } + assert_eq!(saved["readingGoal"]["mode"], "inherit"); + assert_eq!(saved["readingDays"], serde_json::json!({})); + } + #[test] + fn goals_completion_and_daily_log_persist_and_reject_invalid_updates_atomically() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("library"); + let source = d.path().join("book.pdf"); + fs::write(&source, b"%PDF-1.7\nfixture").unwrap(); + let mut library = Library::open(root.clone()).unwrap(); + let (book, _) = library.import(&source).unwrap(); + let patch: Patch = serde_json::from_value(serde_json::json!({ + "pages":50,"page":39,"top":123.5,"left":8.0,"finished":true, + "readingGoal":{"mode":"custom","unit":"chapters","target":2}, + "readingDays":{"2026-10-29":{"pages":[3,2,3],"chapters":[2],"pageAdjustment":5}} + })) + .unwrap(); + library.update(&book.id, patch).unwrap(); + let before = fs::read(root.join("library.json")).unwrap(); + for invalid in [ + serde_json::json!({"finished":false,"readingGoal":{"mode":"custom","target":0}}), + serde_json::json!({"title":"changed","readingDays":{"2026-02-30":{"pages":[1]}}}), + serde_json::json!({"readingDays":{"2026-10-29":{"pages":[51]}}}), + ] { + assert!(library + .update(&book.id, serde_json::from_value(invalid).unwrap()) + .is_err()); + assert_eq!(fs::read(root.join("library.json")).unwrap(), before); + assert!(library.catalog.books[0].finished); + } + library.remove(&book.id).unwrap(); + drop(library); + let mut library = Library::open(root).unwrap(); + library.restore(&book.id).unwrap(); + let restored = &library.catalog.books[0]; + assert_eq!(restored.page, 39); + assert_eq!(restored.top, Some(123.5)); + assert_eq!(restored.left, Some(8.0)); + assert!(restored.finished); + assert_eq!(restored.reading_goal.target, 2); + assert_eq!(restored.reading_days["2026-10-29"].pages, vec![2, 3]); + assert_eq!(restored.reading_days["2026-10-29"].page_adjustment, 5); + library + .update( + &book.id, + Patch { + finished: Some(false), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(library.catalog.books[0].page, 39); + assert!(!library.catalog.books[0].finished); + } + #[test] fn independent_copy_dedup_resume_and_restore() { let d = tempfile::tempdir().unwrap(); let source = d.path().join("origem.pdf"); @@ -391,4 +875,118 @@ mod tests { assert!(recovered.warning.is_some()); assert!(recovered.path(&b.id).unwrap().exists()); } + #[test] + fn purge_deletes_copies_keeps_history_and_refuses_documents_outside_removidos() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("lib"); + let mut l = Library::open(root.clone()).unwrap(); + let first = d.path().join("first.pdf"); + let second = d.path().join("second.pdf"); + fs::write(&first, b"%PDF-1.4\nfirst").unwrap(); + fs::write(&second, b"%PDF-1.4\nsecond").unwrap(); + let (a, _) = l.import(&first).unwrap(); + let (b, _) = l.import(&second).unwrap(); + let copy = root.join("pdfs").join(format!("{}.pdf", a.id)); + let cover = root.join("covers").join(format!("{}.png", a.id)); + fs::write(&cover, b"png").unwrap(); + let day = crate::reading::ReadingDay { + page_adjustment: 5, + ..Default::default() + }; + l.update( + &a.id, + Patch { + reading_days: Some([("2026-10-28".to_string(), day)].into()), + ..Default::default() + }, + ) + .unwrap(); + l.remove(&a.id).unwrap(); + // A document still in the library is refused, and nothing is deleted. + assert!(l.purge(&[a.id.clone(), b.id.clone()]).is_err()); + assert_eq!(l.catalog.trash.len(), 1); + assert!(copy.exists() && cover.exists()); + let result = l.purge(&[a.id.clone(), a.id.clone()]).unwrap(); + assert_eq!(result.purged, 1); + assert_eq!(result.freed, a.size); + assert!(result.failed.is_empty()); + assert!(!copy.exists() && !cover.exists()); + assert!(l.path(&b.id).unwrap().exists()); + drop(l); + let mut l = Library::open(root).unwrap(); + assert!(l.catalog.trash.is_empty()); + assert_eq!(l.catalog.archived.len(), 1); + let archived = &l.catalog.archived[0]; + assert_eq!( + (archived.id.as_str(), archived.title.as_str()), + (a.id.as_str(), "first") + ); + assert_eq!(archived.reading_days["2026-10-28"].page_adjustment, 5); + // The same bytes imported again make a new document, not the archived one. + let (again, dup) = l.import(&first).unwrap(); + assert!(!dup); + assert_ne!(again.id, a.id); + // A copy already gone is not a failure. + l.remove(&again.id).unwrap(); + fs::remove_file(l.root.join("pdfs").join(format!("{}.pdf", again.id))).unwrap(); + let result = l.purge(std::slice::from_ref(&again.id)).unwrap(); + assert_eq!((result.purged, result.freed), (1, 0)); + assert!(result.failed.is_empty()); + } + #[test] + fn tags_are_trimmed_deduplicated_and_limited() { + let tags = normalize_tags(vec![ + " Ficção científica ".into(), + "ficcao cientifica".into(), + "".into(), + "Estudo".into(), + ]) + .unwrap(); + assert_eq!(tags, ["Ficção científica", "Estudo"]); + assert!(normalize_tags(vec!["x".repeat(MAX_TAG_CHARS + 1)]).is_err()); + assert!(normalize_tags(vec!["a\u{7}b".into()]).is_err()); + assert_eq!( + normalize_tags((0..MAX_TAGS).map(|n| format!("t{n}")).collect()) + .unwrap() + .len(), + MAX_TAGS + ); + assert!(normalize_tags((0..=MAX_TAGS).map(|n| format!("t{n}")).collect()).is_err()); + let d = tempfile::tempdir().unwrap(); + let mut l = Library::open(d.path().join("lib")).unwrap(); + let p = d.path().join("tagged.pdf"); + fs::write(&p, b"%PDF-1.4\ntags").unwrap(); + let (b, _) = l.import(&p).unwrap(); + let refused = l.update( + &b.id, + Patch { + favorite: Some(true), + tags: Some(vec!["y".repeat(MAX_TAG_CHARS + 1)]), + ..Default::default() + }, + ); + assert!(refused.is_err()); + assert!(!l.catalog.books[0].favorite); + } + #[test] + fn folders_expand_to_their_pdfs_without_following_other_files() { + let d = tempfile::tempdir().unwrap(); + let folder = d.path().join("Livros"); + fs::create_dir_all(folder.join("b").join("c")).unwrap(); + fs::write(folder.join("b").join("c").join("Fundo.PDF"), b"%PDF-1.4\n1").unwrap(); + fs::write(folder.join("a.pdf"), b"%PDF-1.4\n2").unwrap(); + fs::write(folder.join("notas.txt"), b"texto").unwrap(); + let chosen = d.path().join("escolhido.txt"); + fs::write(&chosen, b"texto").unwrap(); + let (files, problems) = expand_import_paths(&[folder.clone(), chosen.clone()]); + assert!(problems.is_empty()); + assert_eq!( + files, + [ + folder.join("a.pdf"), + folder.join("b").join("c").join("Fundo.PDF"), + chosen + ] + ); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6888bcb..e266581 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,19 +1,75 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod commands; +mod launch; mod library; +mod notes; +mod reading; use commands::*; +use launch::{pdf_args, PendingFiles}; use library::Library; use std::sync::{Arc, Mutex}; -use tauri::Manager; +use tauri::{Manager, PhysicalSize, WebviewWindow}; +use tauri_plugin_window_state::StateFlags; +// How far a window may stick out of a screen: Windows keeps an invisible border around it. +const SLACK: i32 = 16; +// The window comes back with the size and the position it was left with, but the screens may have +// changed since: another monitor, a lower resolution, a laptop out of its dock. One that no longer +// fits any screen shrinks to the one it is closest to, if it must, and goes to its middle, as on +// the first launch. A maximized window always fits. +fn fit_to_screen(window: &WebviewWindow) { + if window.is_maximized().unwrap_or(true) { + return; + } + let (Ok(position), Ok(outer), Ok(inner), Ok(monitors)) = ( + window.outer_position(), + window.outer_size(), + window.inner_size(), + window.available_monitors(), + ) else { + return; + }; + let fits = monitors.iter().any(|m| { + let area = m.work_area(); + position.x >= area.position.x - SLACK + && position.y >= area.position.y - SLACK + && position.x + outer.width as i32 <= area.position.x + area.size.width as i32 + SLACK + && position.y + outer.height as i32 <= area.position.y + area.size.height as i32 + SLACK + }); + if fits { + return; + } + if let Ok(Some(monitor)) = window.current_monitor() { + let area = monitor.work_area().size; + // The size of the window is the inside of it: the title bar and the borders come on top. + let frame_width = outer.width.saturating_sub(inner.width); + let frame_height = outer.height.saturating_sub(inner.height); + let _ = window.set_size(PhysicalSize::new( + inner.width.min(area.width.saturating_sub(frame_width)), + inner.height.min(area.height.saturating_sub(frame_height)), + )); + } + let _ = window.center(); +} fn main() { + // Size, position and maximized state, kept between launches. Fullscreen is not: it is a state + // of the moment, and the window never opens in it. + let window_state = tauri_plugin_window_state::Builder::new() + .with_state_flags(StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED); + // A development build keeps its own file, as it keeps its own library. + #[cfg(debug_assertions)] + let window_state = window_state.with_filename("window-state.development.json"); tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, _, _| { + // Managed before the plugins: a second launch may reach the callback below at any time. + .manage(PendingFiles::default()) + .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + launch::receive(app, &args, &cwd); if let Some(w) = app.get_webview_window("main") { let _ = w.unminimize(); let _ = w.set_focus(); } })) .plugin(tauri_plugin_dialog::init()) + .plugin(window_state.build()) .setup(|app| { let root = app.path().app_local_data_dir()?; #[cfg(debug_assertions)] @@ -27,18 +83,33 @@ fn main() { .allow_directory(root.join("covers"), true)?; } app.manage(Arc::new(Mutex::new(lib))); + // The PDFs this launch came with (the first instance; a second one hands its own over). + // The window asks for them when it is ready. + let cwd = std::env::current_dir().unwrap_or_default(); + app.state::() + .push(pdf_args(std::env::args_os().skip(1), &cwd)); + if let Some(window) = app.get_webview_window("main") { + fit_to_screen(&window); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ library_list, import_pdfs, + take_pending_files, update_book, + update_books, remove_book, + remove_books, restore_book, + restore_books, + purge_books, pdf_path, save_cover, open_folder, - export_pdf + reveal_file, + export_pdf, + export_notes ]) .run(tauri::generate_context!()) .expect("Falha ao iniciar o Fólio"); diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs new file mode 100644 index 0000000..31e5451 --- /dev/null +++ b/src-tauri/src/notes.rs @@ -0,0 +1,300 @@ +// Notes and highlights: what the catalog accepts of them, and the Markdown they are exported to. +// A highlight is a note with `rects` (one rectangle in PDF units for each line of text, as +// [x1, y1, x2, y2]) and a colour; its text may be empty. Nothing here touches the PDF. +use crate::library::{Book, Note, Result}; +use std::{cmp::Ordering, io::Write, path::Path}; + +// The colours a highlight can have, by the name that is saved. +pub const COLORS: [&str; 4] = ["yellow", "green", "blue", "pink"]; +// The most notes a document may have, and the longest text or quote of one, in bytes. +const MAX_NOTES: usize = 10_000; +const MAX_NOTE_BYTES: usize = 20_000; +// The most rectangles of one highlight (about a page of lines is a hundred), and of a document. +pub const MAX_NOTE_RECTS: usize = 500; +const MAX_BOOK_RECTS: usize = 100_000; +// PDF pages are a few thousand units wide at most; this only keeps absurd values out. +const MAX_COORDINATE: f64 = 1_000_000.0; + +fn colour_name(color: &str) -> &'static str { + match color { + "green" => "verde", + "blue" => "azul", + "pink" => "rosa", + _ => "amarelo", + } +} + +// The notes as they are kept, or the reason they are refused (nothing is changed then). A note +// without rectangles is a plain note: it has no colour. Empty rectangles are no rectangles. +pub fn normalize(mut notes: Vec) -> Result> { + if notes.len() > MAX_NOTES + || notes + .iter() + .any(|n| n.text.len() > MAX_NOTE_BYTES || n.quote.len() > MAX_NOTE_BYTES) + { + return Err("Limite de notas excedido.".into()); + } + let mut total = 0; + for note in &mut notes { + if note.rects.as_ref().is_some_and(|r| r.is_empty()) { + note.rects = None; + } + if let Some(color) = ¬e.color { + if !COLORS.contains(&color.as_str()) { + return Err("Cor de realce inválida.".into()); + } + } + match ¬e.rects { + Some(rects) => { + if rects.len() > MAX_NOTE_RECTS { + return Err(format!( + "Um realce pode ter no máximo {MAX_NOTE_RECTS} linhas." + )); + } + if rects + .iter() + .flatten() + .any(|v| !v.is_finite() || v.abs() > MAX_COORDINATE) + { + return Err("Posição de realce inválida.".into()); + } + total += rects.len(); + } + None => note.color = None, + } + } + if total > MAX_BOOK_RECTS { + return Err("Limite de realces excedido.".into()); + } + Ok(notes) +} + +// How high on the page the first line of a highlight is (the larger y is higher in PDF space). +fn top_of(note: &Note) -> Option { + note.rects + .as_ref()? + .iter() + .map(|r| r[1].max(r[3])) + .max_by(f64::total_cmp) +} + +// Reading order: by page, the highlights from the top of the page down, then the notes that +// quote nothing in a place, and by the time they were made when nothing else tells them apart. +fn reading_order(a: &Note, b: &Note) -> Ordering { + a.page + .cmp(&b.page) + .then_with(|| match (top_of(a), top_of(b)) { + (Some(x), Some(y)) => y.total_cmp(&x), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }) + .then_with(|| a.created.cmp(&b.created)) + .then_with(|| a.id.cmp(&b.id)) +} + +// A day in the proleptic Gregorian calendar, from the days since 1970-01-01. +fn civil(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + let year = yoe + era * 400 + i64::from(month <= 2); + (year, month, day) +} + +// "29/10/2026", for the moment a note was made, in the reader's time zone. The web side saves +// milliseconds; seconds are accepted too. Nothing when the note has no date. +fn date_of(created: u64, offset_minutes: i32) -> Option { + if created == 0 { + return None; + } + let seconds = i64::try_from(if created > 100_000_000_000 { + created / 1000 + } else { + created + }) + .ok()?; + let local = seconds + i64::from(offset_minutes.clamp(-14 * 60, 14 * 60)) * 60; + let (year, month, day) = civil(local.div_euclid(86_400)); + Some(format!("{day:02}/{month:02}/{year:04}")) +} + +// A title or an author on one line. +fn one_line(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +// The notes of a document as Markdown: the title, the author when there is one, and then the +// notes by page, each with its quote, its text and its date. `offset_minutes` is the reader's +// distance from UTC (east is positive), so that a note made at night keeps its day. +pub fn markdown(book: &Book, offset_minutes: i32) -> String { + let title = one_line(&book.title); + let mut out = format!( + "# {}\n", + if title.is_empty() { + "Documento sem título" + } else { + &title + } + ); + let author = one_line(&book.author); + if !author.is_empty() { + out.push_str(&format!("\nAutor: {author}\n")); + } + let mut notes: Vec<&Note> = book.notes.iter().collect(); + notes.sort_by(|a, b| reading_order(a, b)); + let mut page = None; + for note in notes { + if page != Some(note.page) { + page = Some(note.page); + out.push_str(&format!("\n## Página {}\n", note.page)); + } + let mut blocks: Vec = vec![]; + let quote = note.quote.trim(); + if !quote.is_empty() { + blocks.push( + quote + .lines() + .map(|line| match line.trim_end() { + "" => ">".to_string(), + line => format!("> {line}"), + }) + .collect::>() + .join("\n"), + ); + } + let text = note.text.trim(); + if !text.is_empty() { + blocks.push(text.to_string()); + } + let kind = match (¬e.rects, text.is_empty()) { + (Some(_), true) => format!( + "Realce {}", + colour_name(note.color.as_deref().unwrap_or("yellow")) + ), + (Some(_), false) => format!( + "Nota com realce {}", + colour_name(note.color.as_deref().unwrap_or("yellow")) + ), + (None, _) => "Nota".to_string(), + }; + blocks.push(match date_of(note.created, offset_minutes) { + Some(date) => format!("*{kind} · {date}*"), + None => format!("*{kind}*"), + }); + out.push_str(&format!("\n{}\n", blocks.join("\n\n"))); + } + out +} + +// Writes the file all at once: a temporary file in the same folder, flushed, then moved over the +// destination, so that a failure never leaves half a file, nor loses the one that was there. +// The destination must be a Markdown file. +pub fn write_markdown(destination: &Path, text: &str) -> Result<()> { + let markdown = destination + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); + if !markdown { + return Err("Escolha um arquivo com a extensão .md.".into()); + } + if destination.is_dir() { + return Err("Escolha um nome de arquivo, não uma pasta.".into()); + } + let folder = match destination.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let mut temp = tempfile::NamedTempFile::new_in(folder).map_err(|e| e.to_string())?; + temp.write_all(text.as_bytes()) + .and_then(|_| temp.as_file().sync_all()) + .map_err(|e| e.to_string())?; + temp.persist(destination).map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn note(id: &str, page: u32, rects: Option>, color: Option<&str>) -> Note { + Note { + id: id.into(), + page, + rects, + color: color.map(Into::into), + ..Default::default() + } + } + #[test] + fn highlights_are_validated_and_plain_notes_lose_their_colour() { + let kept = normalize(vec![ + note("a", 1, Some(vec![[1.0, 2.0, 3.0, 4.0]]), Some("green")), + note("b", 1, Some(vec![]), Some("pink")), + note("c", 2, None, Some("blue")), + ]) + .unwrap(); + assert_eq!(kept[0].color.as_deref(), Some("green")); + assert!(kept[1].rects.is_none() && kept[1].color.is_none()); + assert!(kept[2].color.is_none()); + let rects = |r: [f64; 4]| vec![note("x", 1, Some(vec![r]), None)]; + assert!(normalize(rects([f64::NAN, 0.0, 1.0, 1.0])).is_err()); + assert!(normalize(rects([0.0, 0.0, 2e6, 1.0])).is_err()); + assert!(normalize(vec![note("x", 1, Some(vec![[0.0; 4]]), Some("red"))]).is_err()); + let many = vec![[0.0, 0.0, 1.0, 1.0]; MAX_NOTE_RECTS + 1]; + assert!(normalize(vec![note("x", 1, Some(many), None)]).is_err()); + } + #[test] + fn markdown_lists_notes_in_reading_order_with_local_dates() { + // 2026-10-29 00:30 UTC is still the 28th three hours west of UTC. + let created = 1_793_233_800_000; + let book = Book { + title: "Livro\nde teste".into(), + author: "Ana Autora".into(), + notes: vec![ + Note { + text: "Depois".into(), + created, + ..note("late", 3, None, None) + }, + Note { + quote: "Linha baixa".into(), + created, + ..note( + "low", + 2, + Some(vec![[0.0, 100.0, 50.0, 110.0]]), + Some("blue"), + ) + }, + Note { + quote: "Linha alta".into(), + text: "Minha ideia".into(), + created, + ..note("high", 2, Some(vec![[0.0, 600.0, 50.0, 610.0]]), None) + }, + ], + ..Default::default() + }; + let text = markdown(&book, -180); + assert!(text.starts_with("# Livro de teste\n\nAutor: Ana Autora\n")); + let high = text.find("> Linha alta").unwrap(); + let low = text.find("> Linha baixa").unwrap(); + let page3 = text.find("## Página 3").unwrap(); + assert!(text.find("## Página 2").unwrap() < high && high < low && low < page3); + assert!(text.contains("Minha ideia\n\n*Nota com realce amarelo · 28/10/2026*")); + assert!(text.contains("*Realce azul · 28/10/2026*")); + assert!(markdown(&book, 0).contains("*Nota · 29/10/2026*")); + } + #[test] + fn export_requires_a_markdown_file() { + let d = tempfile::tempdir().unwrap(); + assert!(write_markdown(&d.path().join("notas.txt"), "x").is_err()); + let destination = d.path().join("notas.MD"); + write_markdown(&destination, "# Notas\n").unwrap(); + assert_eq!(std::fs::read_to_string(destination).unwrap(), "# Notas\n"); + } +} diff --git a/src-tauri/src/reading.rs b/src-tauri/src/reading.rs new file mode 100644 index 0000000..036d83e --- /dev/null +++ b/src-tauri/src/reading.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +// A document has a goal of its own (`Custom`) or none of its own. `Inherit` and `None` both mean +// "no goal of its own": the document only counts in the general goal, which is all the reading of +// the day. The three values stay valid, so catalogs and goal histories written by earlier versions +// keep opening as they were; the interface saves `Inherit` for "no goal of its own". +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +#[serde(rename_all = "camelCase")] +pub enum GoalMode { + #[default] + Inherit, + Custom, + None, +} +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +#[serde(rename_all = "camelCase")] +pub enum GoalUnit { + #[default] + Pages, + Chapters, +} +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase", default)] +pub struct BookGoal { + pub mode: GoalMode, + pub unit: GoalUnit, + pub target: u32, +} +impl Default for BookGoal { + fn default() -> Self { + Self { + mode: GoalMode::Inherit, + unit: GoalUnit::Pages, + target: 10, + } + } +} +impl BookGoal { + pub fn validate(&self) -> Result<(), String> { + if !(1..=10000).contains(&self.target) { + return Err("A meta deve estar entre 1 e 10.000.".into()); + } + Ok(()) + } +} +#[derive(Serialize, Deserialize, Clone, Default, Debug)] +#[serde(rename_all = "camelCase", default)] +pub struct ReadingDay { + pub pages: Vec, + pub chapters: Vec, + pub page_adjustment: i32, + pub chapter_adjustment: i32, +} +pub type ReadingDays = BTreeMap; +pub type GoalHistory = BTreeMap; +pub fn valid_day(key: &str) -> bool { + let parts: Vec<_> = key.split('-').collect(); + if parts.len() != 3 || parts[0].len() != 4 || parts[1].len() != 2 || parts[2].len() != 2 { + return false; + } + let (Ok(year), Ok(month), Ok(day)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) else { + return false; + }; + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); + let days = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + (1900..=9999).contains(&year) && (1..=12).contains(&month) && day >= 1 && day <= days[month - 1] +} +pub fn validate_days(mut days: ReadingDays, pages: u32) -> Result { + if days.len() > 36500 { + return Err("Histórico diário excede o limite de 100 anos.".into()); + } + for (key, day) in &mut days { + if !valid_day(key) + || day.pages.len() > 100000 + || day.chapters.len() > 10000 + || day.page_adjustment.unsigned_abs() > 100000 + || day.chapter_adjustment.unsigned_abs() > 100000 + || day + .pages + .iter() + .chain(&day.chapters) + .any(|n| *n == 0 || *n > pages) + { + return Err("Registro diário inválido.".into()); + } + day.pages.sort_unstable(); + day.pages.dedup(); + day.chapters.sort_unstable(); + day.chapters.dedup(); + } + Ok(days) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9d58ad4..a209759 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Fólio", - "version": "1.3.0", + "version": "1.8.0", "identifier": "com.lume.pdf", "build": { "beforeDevCommand": "npm run dev", @@ -37,12 +37,22 @@ "shortDescription": "Leitor de PDF com biblioteca local e leitura por telas.", "category": "Productivity", "icon": ["icons/icon.ico", "icons/icon.png"], + "fileAssociations": [ + { + "ext": ["pdf"], + "name": "Folio.PDF", + "description": "Documento PDF", + "mimeType": "application/pdf", + "role": "Viewer" + } + ], "windows": { "nsis": { "installMode": "currentUser", "displayLanguageSelector": false, "languages": ["PortugueseBR"], - "startMenuFolder": "Fólio" + "startMenuFolder": "Fólio", + "installerHooks": "windows/installer-hooks.nsh" }, "webviewInstallMode": { "type": "skip" diff --git a/src-tauri/windows/installer-hooks.nsh b/src-tauri/windows/installer-hooks.nsh new file mode 100644 index 0000000..8c44d53 --- /dev/null +++ b/src-tauri/windows/installer-hooks.nsh @@ -0,0 +1,53 @@ +; Folio is offered in "Open with" for .pdf, and is never made the default program of PDFs. +; +; The file association of Tauri (bundle > fileAssociations) writes the class "Folio.PDF" and then +; makes it the program of .pdf for the user: it sets the default value of +; HKCU\Software\Classes\.pdf, and keeps the previous one in the value "Folio.PDF_backup". Without +; a choice made by the user in Windows (Settings > Default apps) that would open every PDF in +; Folio. The hooks keep the class and undo the rest: the previous program of .pdf comes back, and +; Folio is listed in OpenWithProgids, which is what "Open with" reads. +; +; Only keys of the current user are touched, and only Folio's own class and values are removed. + +!macro NSIS_HOOK_POSTINSTALL + Push $R0 + ReadRegStr $R0 SHCTX "Software\Classes\.pdf" "Folio.PDF_backup" + ; Nothing before it, or an association that was already ours: .pdf goes back to having no + ; program of its own at this level, which leaves it to the user's choice and to the system. + ${If} $R0 == "" + ${OrIf} $R0 == "Folio.PDF" + DeleteRegValue SHCTX "Software\Classes\.pdf" "" + ${Else} + WriteRegStr SHCTX "Software\Classes\.pdf" "" "$R0" + ${EndIf} + DeleteRegValue SHCTX "Software\Classes\.pdf" "Folio.PDF_backup" + WriteRegStr SHCTX "Software\Classes\.pdf\OpenWithProgids" "Folio.PDF" "" + !insertmacro UPDATEFILEASSOC + Pop $R0 +!macroend + +; The uninstaller of Tauri restores the value that "Folio.PDF_backup" holds. It is set to the +; program that .pdf has now, so that the uninstall does not undo a choice made after the install. +!macro NSIS_HOOK_PREUNINSTALL + Push $R0 + ReadRegStr $R0 SHCTX "Software\Classes\.pdf" "" + WriteRegStr SHCTX "Software\Classes\.pdf" "Folio.PDF_backup" "$R0" + Pop $R0 +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + Push $R0 + ; The class is gone: a default value that still names it, or an empty one that the uninstaller + ; wrote, would leave .pdf pointing at nothing. + ReadRegStr $R0 SHCTX "Software\Classes\.pdf" "" + ${If} $R0 == "" + ${OrIf} $R0 == "Folio.PDF" + DeleteRegValue SHCTX "Software\Classes\.pdf" "" + ${EndIf} + DeleteRegValue SHCTX "Software\Classes\.pdf" "Folio.PDF_backup" + DeleteRegValue SHCTX "Software\Classes\.pdf\OpenWithProgids" "Folio.PDF" + DeleteRegKey /ifempty SHCTX "Software\Classes\.pdf\OpenWithProgids" + DeleteRegKey /ifempty SHCTX "Software\Classes\.pdf" + !insertmacro UPDATEFILEASSOC + Pop $R0 +!macroend diff --git a/src/hints.ts b/src/hints.ts new file mode 100644 index 0000000..00f7ee4 --- /dev/null +++ b/src/hints.ts @@ -0,0 +1,21 @@ +// The tips that are shown only once. A tip is remembered by name in the preferences, so that it +// stays quiet after the first time, also after a restart. The names are part of the saved data. +export const HINTS = ['kindle', 'finish', 'fullscreen'] as const; +export type HintId = (typeof HINTS)[number]; + +const known = new Set(HINTS); + +// What was saved, without what is not a known tip and without repeats. Older preferences have none. +export function normalizeHints(value: unknown): HintId[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.filter((id): id is HintId => typeof id === 'string' && known.has(id)))]; +} + +export function hasSeenHint(seen: readonly string[], id: HintId): boolean { + return seen.includes(id); +} + +// The list with one more tip seen. The same list again if it was already there. +export function withHint(seen: readonly string[], id: HintId): HintId[] { + return normalizeHints(hasSeenHint(seen, id) ? seen : [...seen, id]); +} diff --git a/src/icons.ts b/src/icons.ts index 18d6443..1665fea 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -20,9 +20,14 @@ const paths: Record = { moon: '', sun: '', warm: '', + // The circle with one half filled, the sign of the display settings: paper, brightness and warmth. + appearance: + '', bookmark: '', note: '', rotate: '', + spread: + '', expand: '', minus: '', download: '', @@ -30,10 +35,18 @@ const paths: Record = { leaf: '', help: '', edit: '', - focus: '', + collapse: '', undo: '', file: '', + tag: '', + archive: '', + target: + '', + history: '', + restart: '', + copy: '', }; export function icon(name: string) { - return ``; + // The width of the line is in pixels, not in units of the drawing (see `svg.icon` in ui.css). + return ``; } diff --git a/src/library/archive.ts b/src/library/archive.ts new file mode 100644 index 0000000..650999a --- /dev/null +++ b/src/library/archive.ts @@ -0,0 +1,31 @@ +import type { ArchivedBook, Book } from '../types'; + +// The reading history takes documents, not records. What a deleted document keeps (its days and +// the goals it had) is put in the shape of a book with nothing else in it, so that its days go on +// being counted where the days of the others are. +export function archivedAsBook(record: ArchivedBook): Book { + return { + id: record.id, + title: record.title, + author: record.author ?? '', + originalName: '', + hash: '', + size: 0, + added: 0, + lastOpened: 0, + page: 1, + pages: 0, + top: null, + left: null, + zoom: 'page-width', + rotation: 0, + favorite: false, + finished: false, + bookmarks: [], + notes: [], + cover: false, + tags: [], + readingDays: record.readingDays ?? {}, + goalHistory: record.goalHistory ?? {}, + }; +} diff --git a/src/library/card-navigation.ts b/src/library/card-navigation.ts new file mode 100644 index 0000000..64f57b4 --- /dev/null +++ b/src/library/card-navigation.ts @@ -0,0 +1,53 @@ +// Moving between the cards of the library with the keys (the pattern of a roving tabindex, from the +// WAI-ARIA Authoring Practices). A card's place is where it is on the screen, since the number of +// columns changes with the width of the window. +export type CardBox = { top: number; left: number; width: number; height: number }; + +// Cards of one row share the same top; a few pixels of difference are the rounding of the layout. +const SAME_ROW = 4; + +// The index of the card that `key` leads to from the card `from`, in the same order as `boxes`. +// It is `from` itself when there is nowhere to go (the first or the last card, the edge of the grid). +// `undefined` when the key does nothing here: another key, or ← and → in the list. +// In the list only ↑ and ↓ move. In the grid ← and → go to the previous and the next card, in reading +// order, and ↑ and ↓ to the card of the row above or below that is closest to it. Home and End go +// to the first and to the last. +export function cardAfterKey( + boxes: readonly CardBox[], + from: number, + key: string, + list: boolean, +): number | undefined { + const last = boxes.length - 1; + if (last < 0 || from < 0 || from > last) return undefined; + const step = (by: number) => Math.min(last, Math.max(0, from + by)); + if (key === 'Home') return 0; + if (key === 'End') return last; + if (key !== 'ArrowUp' && key !== 'ArrowDown' && key !== 'ArrowLeft' && key !== 'ArrowRight') + return undefined; + const vertical = key === 'ArrowUp' || key === 'ArrowDown'; + if (list) return vertical ? step(key === 'ArrowDown' ? 1 : -1) : undefined; + if (!vertical) return step(key === 'ArrowRight' ? 1 : -1); + const down = key === 'ArrowDown', + here = boxes[from], + centre = here.left + here.width / 2; + const away = (box: CardBox) => (down ? box.top - here.top : here.top - box.top); + // The nearest row in that direction, and in it the card whose middle is closest to this one's. + let row = Infinity; + for (const box of boxes) { + const distance = away(box); + if (distance > SAME_ROW && distance < row) row = distance; + } + if (row === Infinity) return from; + let best = from, + gap = Infinity; + boxes.forEach((box, i) => { + if (Math.abs(away(box) - row) > SAME_ROW) return; + const apart = Math.abs(box.left + box.width / 2 - centre); + if (apart < gap) { + gap = apart; + best = i; + } + }); + return best; +} diff --git a/src/library/enrich.ts b/src/library/enrich.ts new file mode 100644 index 0000000..88d6ef6 --- /dev/null +++ b/src/library/enrich.ts @@ -0,0 +1,78 @@ +import { asset, invoke } from '../bridge'; +import type { Book } from '../types'; +import { metadataPatch } from './metadata'; + +// What the job needs from the rest of the app. +export type EnrichHost = { + // The book as the library has it now; `undefined` once it was removed. + find: (id: string) => Book | undefined; + // A book was saved with something new (cover, page count, author or title). + changed: (book: Book) => void; +}; + +// What PDF.js is working on, or a rejection as soon as `signal` is aborted. A paused queue aborts +// the reading of the file, and PDF.js would wait forever for a part that it was never given. +function until(signal: AbortSignal, work: Promise): Promise { + return new Promise((resolve, reject) => { + const stop = () => reject(signal.reason); + if (signal.aborted) return stop(); + signal.addEventListener('abort', stop, { once: true }); + void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', stop)); + }); +} + +// Gives a book what the import left out: the cover, the page count, the author and the title of +// the PDF. It opens the copy kept in the library, one document at a time, and frees it before +// returning, whatever happens. Rejects when the PDF cannot be opened (a password, a broken file) +// or when `signal` is aborted before anything is saved; the queue treats both in silence. +export async function enrichBook(id: string, signal: AbortSignal, host: EnrichHost) { + const book = host.find(id); + if (!book) return; + // PDF.js is loaded only when there is something to read: a library that is complete never pays. + const { openPdfSource, renderCover } = await import('../pdf-source'); + signal.throwIfAborted(); + const path = await invoke('pdf_path', { id }); + const source = await openPdfSource(asset(path), book.size, { signal }); + if (!source) throw signal.reason; + // Whatever happens, the document goes: on a pause it goes at once, which also stops the work. + const release = () => void source.task.destroy().catch(() => {}); + signal.addEventListener('abort', release, { once: true }); + try { + const pdf = await until(signal, source.document); + const meta = await until( + signal, + pdf.getMetadata().catch(() => null), + ); + const info = (meta?.info ?? {}) as { Title?: unknown; Author?: unknown }; + let cover: Uint8Array | null = null; + if (!book.cover) { + const page = await until(signal, pdf.getPage(1)); + try { + cover = await until(signal, renderCover(page)); + } finally { + try { + page.cleanup(); + } catch { + /* The document may already be gone: there is nothing to free. */ + } + } + } + // From here on the job is only two quick writes, and it is let finish. + signal.throwIfAborted(); + let saved = host.find(id); + if (!saved) return; + if (cover) saved = await invoke('save_cover', { id, bytes: Array.from(cover) }); + const patch = metadataPatch(saved, { + pages: pdf.numPages, + title: info.Title, + author: info.Author, + }); + if (Object.keys(patch).length) saved = await invoke('update_book', { id, patch }); + // Without a cover and without a patch there is nothing that changed. + if (cover || Object.keys(patch).length) host.changed(saved); + } finally { + signal.removeEventListener('abort', release); + // Frees the document, its worker and what it decoded. + await source.task.destroy().catch(() => {}); + } +} diff --git a/src/library/metadata.ts b/src/library/metadata.ts new file mode 100644 index 0000000..1710ea4 --- /dev/null +++ b/src/library/metadata.ts @@ -0,0 +1,96 @@ +import type { Book } from '../types'; + +// What the library queue takes from a PDF, and what it does with it. Pure: no DOM and no PDF.js, +// so that the rules can be tested alone. + +// A book the queue has something to give: no cover yet, or no page count. +export function needsMetadata(book: Pick): boolean { + return !book.cover || !book.pages; +} + +// The most characters of a title or an author that the library keeps from a PDF. +export const MAX_TITLE = 200; +export const MAX_AUTHOR = 200; +// A title shorter than this says too little to replace the name of the file. +const MIN_TITLE = 3; + +// Text from a PDF's information, as one tidy line: no control characters, spaces collapsed. +// Anything that is not text is nothing. +export function cleanText(value: unknown): string { + if (typeof value !== 'string') return ''; + return value + .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\ufeff]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// The name of a file as the library first calls the document: without the `.pdf`. +export function fileStem(originalName: string): string { + return originalName.replace(/\.pdf$/i, ''); +} + +// What programs put in the Title of a PDF when nobody wrote one. +const PLACEHOLDERS = [ + // "Untitled", "Untitled-1", "Sem título 2", "Documento1", "Novo documento", "Slide 1". + /^(untitled|no title|sem t[ií]tulo|sem nome|t[ií]tulo|title|unbenannt|sin t[ií]tulo)(?:[\s._-]*(?:document|documento|presentation|apresenta[cç][aã]o))?[\s._-]*\d*$/i, + /^(new |novo )?(document|documento|doc|presentation|apresenta[cç][aã]o|slide|planilha|spreadsheet|sheet|page|p[aá]gina)[\s._-]*\d*$/i, + // What a scanner, a camera or a print driver names a file. + /^(scan|scanned|img|image|imagem|dsc|cci|print|copy|c[oó]pia|file|arquivo)[\s._-]*\d+$/i, + // "Microsoft Word - Relatório", "Microsoft PowerPoint - aula.pptx". + /^microsoft[\s®]+(office[\s®]+)?(word|excel|powerpoint|publisher|visio|access|project|outlook|onenote)\b/i, + /^(powerpoint presentation|apresenta[cç][aã]o do powerpoint)\b/i, + // A path. + /^[a-z]:[\\/]/i, +]; +// A title that is a file name: "relatorio_final.doc", "Livro.pdf". +const FILE_NAME = /\.(docx?|dotx?|pptx?|xlsx?|odt|ods|odp|rtf|txt|pdf|indd|tex|pub|psd|html?)$/i; + +// The title of a PDF if it is worth showing instead of the name of the file, or `null`. It is not +// worth showing when it is empty, too short or too long, "untitled", the name of a program +// ("Microsoft Word - …"), a file name, or has no letter in it (only numbers or punctuation). +export function plausibleTitle(raw: unknown): string | null { + const title = cleanText(raw); + if (title.length < MIN_TITLE || title.length > MAX_TITLE) return null; + if (!/\p{L}/u.test(title)) return null; + if (FILE_NAME.test(title)) return null; + if (PLACEHOLDERS.some((rule) => rule.test(title))) return null; + return title; +} + +// Names that stand for nobody: what a program fills in when it does not know the author. +const NOBODY = + /^(unknown|desconhecido|an[oô]nimo|anonymous|admin|administrator|administrador|user|usu[aá]rio|owner|author|autor|n\/?a|none|null|undefined|windows user|usu[aá]rio do windows)$/i; + +// The author of a PDF, as one short line, or an empty text when there is nothing to show. +export function cleanAuthor(raw: unknown): string { + const author = cleanText(raw); + if (!/\p{L}/u.test(author) || NOBODY.test(author)) return ''; + return author.length > MAX_AUTHOR ? `${author.slice(0, MAX_AUTHOR - 1).trimEnd()}…` : author; +} + +export type PdfFacts = { + pages: number; + // `info.Title` and `info.Author` of the PDF, as PDF.js gives them. + title?: unknown; + author?: unknown; +}; +export type MetadataPatch = Partial>; + +// What to write to the book, given what the PDF says: only what is new. The page count when it +// differs; the author when there is one; the title only while the book still has the name that +// the import gave it (the file name without extension, so the reader never renamed it) and the +// PDF's own title is plausible. +export function metadataPatch( + book: Pick, + facts: PdfFacts, +): MetadataPatch { + const patch: MetadataPatch = {}; + if (Number.isInteger(facts.pages) && facts.pages > 0 && facts.pages !== book.pages) + patch.pages = facts.pages; + const author = cleanAuthor(facts.author); + if (author && author !== book.author) patch.author = author; + const title = plausibleTitle(facts.title); + if (title && title !== book.title && book.title === fileStem(book.originalName)) + patch.title = title; + return patch; +} diff --git a/src/library/queue.ts b/src/library/queue.ts new file mode 100644 index 0000000..d13a99e --- /dev/null +++ b/src/library/queue.ts @@ -0,0 +1,75 @@ +// A queue that does one job at a time, in the background, and steps aside when asked. It knows +// nothing about PDFs: `work` does the job for an id. Pure scheduling, so it can be tested alone. +// +// - An id is taken once per session, whatever happens to it: a job that fails is not tried again. +// - `pause()` stops what is running and holds the rest. The job that was cut short goes back to +// the front of the line and is not counted as a failure. +// - `resume()` starts again after a short delay, so that leaving and entering a screen in quick +// succession does not start work that is stopped a moment later. +export type QueueWork = (id: string, signal: AbortSignal) => Promise; + +export class WorkQueue { + private waiting: string[] = []; + private seen = new Set(); + private control: AbortController | null = null; + private running = false; + private paused = false; + private timer: ReturnType | undefined; + private work: QueueWork; + private delay: number; + + constructor(work: QueueWork, delay = 300) { + this.work = work; + this.delay = delay; + } + + // Adds ids that were not taken before. Work starts at once, unless the queue is paused. + add(ids: Iterable) { + for (const id of ids) { + if (this.seen.has(id)) continue; + this.seen.add(id); + this.waiting.push(id); + } + this.start(); + } + + pause() { + this.paused = true; + clearTimeout(this.timer); + this.timer = undefined; + this.control?.abort(); + } + + resume() { + this.paused = false; + clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = undefined; + this.start(); + }, this.delay); + } + + private start() { + if (this.running || this.paused || this.timer !== undefined || !this.waiting.length) return; + void this.run(); + } + + private async run() { + this.running = true; + try { + while (!this.paused && this.waiting.length) { + const id = this.waiting.shift()!; + const control = (this.control = new AbortController()); + try { + await this.work(id, control.signal); + } catch { + // Cut short by a pause: its turn comes again. Anything else is given up, in silence. + if (control.signal.aborted) this.waiting.unshift(id); + } + this.control = null; + } + } finally { + this.running = false; + } + } +} diff --git a/src/library/search.ts b/src/library/search.ts new file mode 100644 index 0000000..59b31b5 --- /dev/null +++ b/src/library/search.ts @@ -0,0 +1,19 @@ +import type { Book } from '../types'; +import { tagsOf } from './tags.ts'; + +// Where a book was found. `notes` means that its title, author, file name and tags do not have +// the text, and only its notes do (the text of a note, or the passage it quotes). +export type SearchHit = 'book' | 'notes'; + +export function searchBook(book: Book, query: string): SearchHit | null { + const wanted = query.toLocaleLowerCase(); + if (!wanted) return 'book'; + const own = `${book.title} ${book.author ?? ''} ${book.originalName} ${tagsOf(book).join(' ')}`; + if (own.toLocaleLowerCase().includes(wanted)) return 'book'; + const inNotes = (book.notes ?? []).some( + (note) => + (note.text ?? '').toLocaleLowerCase().includes(wanted) || + (note.quote ?? '').toLocaleLowerCase().includes(wanted), + ); + return inNotes ? 'notes' : null; +} diff --git a/src/library/selection.ts b/src/library/selection.ts new file mode 100644 index 0000000..ce144d1 --- /dev/null +++ b/src/library/selection.ts @@ -0,0 +1,25 @@ +// The documents ticked in the library, by id. The ids that a drawing of the list took off the +// screen (a search, a filter, a removal) are not selected any more: what the bar says is what the +// actions do. + +// The ids between the one that was ticked last (the anchor) and the target, both included, in the +// order of the list on screen. Without an anchor, or with one that is not on screen, only the +// target. +export function rangeIds(order: readonly string[], anchor: string | null, target: string) { + const to = order.indexOf(target); + if (to < 0) return []; + const from = anchor === null ? -1 : order.indexOf(anchor); + if (from < 0) return [target]; + return order.slice(Math.min(from, to), Math.max(from, to) + 1); +} +// Takes out of `selected` what is not in `visible`. Says whether something was taken out. +export function pruneSelection(selected: Set, visible: Iterable) { + const shown = new Set(visible); + let changed = false; + for (const id of [...selected]) + if (!shown.has(id)) { + selected.delete(id); + changed = true; + } + return changed; +} diff --git a/src/library/tags.ts b/src/library/tags.ts new file mode 100644 index 0000000..4aad1df --- /dev/null +++ b/src/library/tags.ts @@ -0,0 +1,102 @@ +import type { Book } from '../types'; + +// The same limits as the catalog (`normalize_tags`, in library.rs), so that a tag the window +// accepts is never refused when it is saved. +export const MAX_TAGS = 20; +export const MAX_TAG_LENGTH = 40; + +// Two spellings are the same tag when they differ only in case or accents ("Ficção" = "ficcao"). +// This is also what a tag filter is named by. +export const tagKey = (tag: string) => tag.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); +// What the reader typed, as it is kept: spaces trimmed, and a run of them made one. +export const cleanTag = (tag: string) => tag.replace(/\s+/g, ' ').trim(); +// A book from before tags, or one a test made by hand, has none. +export const tagsOf = (book: Pick): string[] => + Array.isArray(book.tags) ? book.tags : []; +export const hasTag = (book: Pick, key: string) => + tagsOf(book).some((tag) => tagKey(tag) === key); + +// The tags as the catalog keeps them: trimmed, no empty ones, no repeats (the first spelling +// stays). +export function normalizeTags(tags: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of tags) { + const tag = cleanTag(String(raw)); + if (tag && !seen.has(tagKey(tag))) { + seen.add(tagKey(tag)); + out.push(tag); + } + } + return out; +} + +export type TagProblem = 'empty' | 'long' | 'duplicate' | 'full'; +// One tag added to a list. When it cannot be, `problem` says why and the list is the same. +export function addTag( + tags: readonly string[], + input: string, +): { tags: string[]; problem?: TagProblem } { + const tag = cleanTag(input); + if (!tag) return { tags: [...tags], problem: 'empty' }; + if ([...tag].length > MAX_TAG_LENGTH) return { tags: [...tags], problem: 'long' }; + if (tags.some((t) => tagKey(t) === tagKey(tag))) return { tags: [...tags], problem: 'duplicate' }; + if (tags.length >= MAX_TAGS) return { tags: [...tags], problem: 'full' }; + return { tags: [...tags, tag] }; +} +export const removeTag = (tags: readonly string[], key: string) => + tags.filter((tag) => tagKey(tag) !== key); +// What `addTag` could not do, in a sentence for the dialog. +export const tagProblemText: Record = { + empty: 'Digite uma etiqueta.', + long: `Use até ${MAX_TAG_LENGTH} caracteres em cada etiqueta.`, + duplicate: 'Essa etiqueta já está na lista.', + full: `Cada documento aceita até ${MAX_TAGS} etiquetas.`, +}; + +export interface TagCount { + key: string; + // How the tag is written: the spelling most books use, the first alphabetically on a tie. + label: string; + count: number; +} +// Every tag of a set of books, once each, with the number of books that have it, in alphabetical +// order. +export function tagCounts(books: readonly Pick[]): TagCount[] { + const byKey = new Map }>(); + for (const book of books) + for (const tag of normalizeTags(tagsOf(book))) { + const entry = byKey.get(tagKey(tag)) ?? { count: 0, spellings: new Map() }; + entry.count++; + entry.spellings.set(tag, (entry.spellings.get(tag) ?? 0) + 1); + byKey.set(tagKey(tag), entry); + } + return [...byKey] + .map(([key, { count, spellings }]) => ({ + key, + count, + label: [...spellings].sort(([a, x], [b, y]) => y - x || a.localeCompare(b, 'pt-BR'))[0][0], + })) + .sort((a, b) => a.label.localeCompare(b.label, 'pt-BR')); +} +// The tags already in use that are worth offering while one is typed: those not yet in the list +// and, when something is typed, those that contain it. The most used come first. +export function suggestTags( + known: readonly TagCount[], + draft: readonly string[], + typed = '', + limit = 12, +): TagCount[] { + const taken = new Set(draft.map(tagKey)); + const part = tagKey(cleanTag(typed)); + return known + .filter((tag) => !taken.has(tag.key) && tag.key.includes(part)) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'pt-BR')) + .slice(0, limit); +} + +// The tag filter of the library is a filter named "tag:" and the key of the tag. +export const TAG_FILTER = 'tag:'; +export const tagFilter = (key: string) => `${TAG_FILTER}${key}`; +export const filterTag = (filter: string) => + filter.startsWith(TAG_FILTER) ? filter.slice(TAG_FILTER.length) : null; diff --git a/src/main.ts b/src/main.ts index c57a559..ebf7ee5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,48 +1,243 @@ import './style.css'; import { icon } from './icons'; -import { $, esc, size, ib } from './ui/dom'; -import { appShell } from './ui/shell'; -import { toast, fail, modal, popover, closePopover } from './ui/overlays'; -import { renderLibraryView } from './ui/library-view'; -import { showPreferences } from './ui/preferences-dialog'; +import { $, documents, esc, size, ib } from './ui/dom'; +import { appShell, SAVE_STATUS, SLOW_SAVE } from './ui/shell'; +import { + toast, + announce, + dismissNotices, + fail, + failing, + ReportedError, + modal, + popover, + closePopover, + type PopoverOptions, + type ToastAction, +} from './ui/overlays'; +import { GENERIC_ERROR, describeError } from './ui/error-text'; +import { FAILED } from './ui/failures'; +import { showImportProgress, hideImportProgress } from './ui/import-progress'; +import { hasSeenHint, withHint, type HintId } from './hints'; +import { + renderLibraryView, + updateBookCard, + updateSelection, + revealBook, + keepLibraryView, +} from './ui/library-view'; +import { showTags } from './ui/tags-dialog'; +import { confirmPurge } from './ui/purge-dialog'; +import { showPreferences, type PreferencesSection } from './ui/preferences-dialog'; import { showHelp } from './ui/help-dialog'; -import { bindShortcuts } from './ui/shortcuts'; +import { bindShortcuts, SPREAD_KINDLE_NOTE } from './ui/shortcuts'; +import { WorkQueue } from './library/queue'; +import { enrichBook } from './library/enrich'; +import { needsMetadata } from './library/metadata'; +import { ReaderChrome } from './ui/reader-chrome'; +import { JumpPill } from './ui/jump-pill'; +import { SelectionBar } from './ui/selection-bar'; +import { deleteLabel, noteMenu } from './ui/note-menu'; +import { + renderNotes, + notesFocusOf, + restoreNotesFocus, + type NotesFocus, + type NotesState, +} from './ui/notes-panel'; +import { copyText } from './ui/clipboard'; +import { PAPER_CHOICES, bindReadingComfort, syncReadingComfort } from './ui/reading-comfort'; +import { showGoals, goalLabel } from './ui/goals-dialog'; +import { showHistory } from './ui/history-dialog'; +import { OutlineView, NO_DESTINATION, type OutlineState } from './ui/outline-view'; +import { ThumbnailList } from './ui/thumbnail-list'; +import { bindSidebarResizer } from './ui/sidebar-resizer'; +import { archivedAsBook } from './library/archive'; +import { rangeIds } from './library/selection'; +import { MAX_TAGS, addTag, filterTag, hasTag, tagCounts, tagsOf } from './library/tags'; +import { rememberGoal } from './reading/history'; +import { + COLOR_LABELS, + NOTE_COLORS, + colorOf, + rememberColor, + rememberedColor, +} from './reading/note-colors'; +import { isHighlight, noteRects } from './reading/note-rects'; +import type { SelectionCapture } from './reading/selection-capture'; +import { + ReadingActivity, + dayRecord, + globalCount, + goalProgress, + localDay, + withCredit, + withDay, +} from './reading/goals'; +import { spreadPages } from './reading/spread'; +import { SIDEBAR_WIDTH, clampSidebarWidth, sidebarMax, tabAfterKey } from './reading/sidebar'; +import { readingProgress } from './reading/progress'; +import { + PaceTracker, + formatDuration, + recordPace, + remainingTime, + secondsPerPage, +} from './reading/pace'; import { invoke, asset } from './bridge'; import { open, save } from '@tauri-apps/plugin-dialog'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { listen } from '@tauri-apps/api/event'; -import type { Book, Snapshot, Preferences, Note } from './types'; +import type { + Book, + ImportResult, + PurgeResult, + Snapshot, + Preferences, + Note, + NoteColor, + SidebarTab, + Spread, +} from './types'; import { loadPreferences, normalizePreferences, savePreferences } from './preferences'; const prefs = loadPreferences(localStorage); -let snapshot: Snapshot = { books: [], trash: [], folder: '', warning: null }; +const activity = new ReadingActivity(); +const pace = new PaceTracker(); +let goalDay = localDay(); +let finishPrompted = false; +let snapshot: Snapshot = { books: [], trash: [], archived: [], folder: '', warning: null }; let filter = 'all', query = '', busy = false, current: Book | null = null, - reader: import('./reader').Reader | null = null, - sideTab = 'outline'; + reader: import('./reader').Reader | null = null; +// The documents ticked in the library, and the one a Shift + click counts the range from. The +// view takes out of the set what it no longer shows. +const selected = new Set(); +let anchor: string | null = null, + // A batch is one at a time: a second press while it runs does nothing. + batching = false; let loading = false, saveTimer: ReturnType | undefined, readingPatch: Partial = {}, saveChain = Promise.resolve(), - resumePosition = false; + resumePosition = false, + // Closing the password dialog is a choice of the reader, not a failure to open the PDF. + passwordDeclined = false, + warned = ''; +// What the toolbar button and the L key turn on: the two-page layout last chosen. +let pairLayout: Exclude = 'odd'; +// The papers, in the order that the D key goes through them (the Aparência panel lists the same). +const PAPERS = PAPER_CHOICES.map(([paper]) => paper); +const PAPER_NAMES: Record = { + light: 'claro', + sepia: 'sépia', + dark: 'escuro', +}; +const nextPaper = (paper: Preferences['paper']) => + PAPERS[(PAPERS.indexOf(paper) + 1) % PAPERS.length]; +// The side bar: what its current tab shows, and how the outline was folded by hand. +let outlineView: OutlineView | null = null, + thumbnails: ThumbnailList | null = null, + renderedTab: SidebarTab | null = null, + shownInSidebar = ''; +const outlineState: OutlineState = { folds: new Map() }; +// The search of the Notas tab, kept while the book is open. +const notesState: NotesState = { query: '' }; +// What the import leaves out (cover, page count, author, a title from the PDF) is read in the +// background, one PDF at a time. It waits while a book is open, and goes on in the library. +const enrichment = new WorkQueue((id, signal) => + enrichBook(id, signal, { + find: (bookId) => snapshot.books.find((b) => b.id === bookId), + changed: bookEnriched, + }), +); +const waitingForMetadata = (books: Book[]) => + books + .filter(needsMetadata) + .sort((a, b) => (b.lastOpened || b.added) - (a.lastOpened || a.added)) + .map((b) => b.id); $('#app').innerHTML = appShell; +const chrome = new ReaderChrome( + getCurrentWindow(), + () => !!current, + () => reader?.resize(), + failing('Não foi possível alterar a tela cheia.'), +); +// The way back after a jump, over the reading surface. +const jumpPill = new JumpPill({ + back: () => stepJump('back'), + forward: () => stepJump('forward'), +}); +// The bar near a passage that is selected in the pages: highlight, annotate, copy. +const selectionBar = new SelectionBar({ + bar: $('#highlight-bar'), + container: $('#viewer-container'), + enabled: () => !!current && !!reader && !loading && !$('#dialog').open, + capture: () => reader?.captureSelection() ?? null, + highlight: (color) => void highlightSelection(color), + annotate: () => void annotateSelection(), + copy: () => void copySelection(), + status: $('#selection-status'), + hint: () => + prefs.singleKeys + ? 'Trecho selecionado. H realça, N adiciona nota e Esc cancela.' + : 'Trecho selecionado. Esc cancela.', +}); +bindReadingComfort(prefs, applyPrefs, () => preferences('aparencia')); function renderLibrary() { + // A tag that no document has any more is no filter: back to the whole library. + const tag = filterTag(filter); + if (tag !== null && !snapshot.books.some((b) => hasTag(b, tag))) filter = 'all'; renderLibraryView( - { snapshot, filter, query, prefs }, - { openBook, importFiles, toggleFavorite, bookMenu, restoreBook, applyPrefs }, + { snapshot, filter, query, prefs, selected }, + { + openBook, + importFiles, + importFolder, + toggleFavorite, + select: selectCard, + bookMenu, + restoreBook, + applyPrefs, + clearSearch, + }, ); } -function preferences() { - showPreferences(prefs, { folder: snapshot.folder, applyPrefs, setReadingMode }); +// The way out of a search that found nothing, from the empty state: the field is cleared and the +// focus goes back to it, since the button that was pressed goes away with the message. +function clearSearch() { + query = ''; + $('#library-search').value = ''; + renderLibrary(); + $('#library-search').focus(); +} +// A book that the queue just completed: its card is drawn again where it is, and the whole list +// only when the new title or author changes which cards are shown, or their order. +function bookEnriched(updated: Book) { + const before = snapshot.books.find((b) => b.id === updated.id); + if (!before) return; + snapshot.books = snapshot.books.map((b) => (b.id === updated.id ? updated : b)); + if (current?.id === updated.id) { + current = { ...current, title: updated.title, author: updated.author, cover: updated.cover }; + updateReaderMeta(); + } + if (!updateBookCard({ snapshot, filter, query, prefs, selected }, updated, before)) + keepLibraryView(renderLibrary); +} +// `section` is the tab the dialog opens on; without it, the one that was used last. +function preferences(section?: PreferencesSection) { + showPreferences(prefs, { folder: snapshot.folder, applyPrefs, setReadingMode }, section); } function applyPrefs() { Object.assign(prefs, normalizePreferences(prefs)); - reader?.setMode(prefs.readingMode); + reader?.setMode(prefs.readingMode, prefs.joinPages, prefs.chapterBreaks, prefs.spread); $('#kindle-toggle').setAttribute('aria-pressed', String(prefs.readingMode === 'kindle')); $('#kindle-toggle').classList.toggle('selected', prefs.readingMode === 'kindle'); + syncSpreadToggle(); + applySidebar(); prefs.brightness = typeof prefs.brightness === 'number' && Number.isFinite(prefs.brightness) ? Math.min(100, Math.max(10, prefs.brightness)) @@ -54,106 +249,503 @@ function applyPrefs() { : 'light' : prefs.theme; document.documentElement.dataset.paper = prefs.paper; + // The button shows where it takes you: the sun on the dark theme, the moon on the light one. + $('#theme-toggle').innerHTML = icon( + document.documentElement.dataset.theme === 'dark' ? 'sun' : 'moon', + ); $('#warm-overlay').style.opacity = String(Math.min(70, Math.max(0, prefs.warm)) / 100); $('#brightness-overlay').style.opacity = String((100 - prefs.brightness) / 100); savePreferences(localStorage, prefs); + updateGoals(); + syncReadingComfort(prefs); updateReaderMeta(); - $('#paper-toggle').classList.toggle('selected', prefs.paper === 'dark'); - $('#view-grid').classList.toggle('selected', prefs.view === 'grid'); - $('#view-list').classList.toggle('selected', prefs.view === 'list'); + // Grid and list are one choice (a radio group): only the chosen one is a tab stop. + for (const view of ['grid', 'list']) { + const on = prefs.view === view, + button = $(`#view-${view}`); + button.classList.toggle('selected', on); + button.setAttribute('aria-checked', String(on)); + button.tabIndex = on ? 0 : -1; + } $('#sort').setAttribute('value', prefs.sort); } +// The Kindle needs one column: the button is off there, and the choice returns after it. +function syncSpreadToggle() { + if (prefs.spread !== 'none') pairLayout = prefs.spread; + const kindle = prefs.readingMode === 'kindle', + on = !kindle && prefs.spread !== 'none', + label = kindle ? SPREAD_KINDLE_NOTE : 'Duas páginas lado a lado (L)'; + const button = $('#spread-toggle'); + button.toggleAttribute('disabled', kindle); + button.setAttribute('aria-pressed', String(on)); + button.setAttribute('aria-label', label); + button.title = label; + button.classList.toggle('selected', on); +} +// The side bar's state is a preference of the reader, not of a book: it opens the way it +// was left, in any document. The width in the preference is the user's; the window can +// show less (at most 40% of it), without changing what is saved. +function applySidebar() { + const open = prefs.sidebarOpen; + $('#reader-sidebar').hidden = !open; + $('#reader-sidebar').style.setProperty('--sidebar-width', `${prefs.sidebarWidth}px`); + $('#sidebar-toggle').setAttribute('aria-expanded', String(open)); + $('#sidebar-toggle').classList.toggle('selected', open); + document.querySelectorAll('[data-tab]').forEach((tab) => { + const active = tab.dataset.tab === prefs.sidebarTab; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', String(active)); + tab.tabIndex = active ? 0 : -1; + }); + $('#sidebar-content').setAttribute('aria-labelledby', `tab-${prefs.sidebarTab}`); + syncResizer(); +} +function syncResizer() { + const side = $('#reader-sidebar'), + handle = $('#sidebar-resizer'); + const width = side.hidden + ? clampSidebarWidth(prefs.sidebarWidth, innerWidth) + : Math.round(side.getBoundingClientRect().width); + handle.setAttribute('aria-valuemin', String(SIDEBAR_WIDTH.min)); + handle.setAttribute('aria-valuemax', String(sidebarMax(innerWidth))); + handle.setAttribute('aria-valuenow', String(width)); + handle.setAttribute('aria-valuetext', `${width} px`); +} applyPrefs(); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyPrefs); async function refresh() { - snapshot = await invoke('library_list'); + const listed = await invoke('library_list', { historyDay: localDay() }); + snapshot = { ...listed, archived: listed.archived ?? [] }; + rememberGoal(prefs); renderLibrary(); - if (snapshot.warning) toast(snapshot.warning); + // The catalog keeps its warning for the whole session: say it once, and for long enough to read. + if (snapshot.warning && snapshot.warning !== warned) { + warned = snapshot.warning; + toast(snapshot.warning, undefined, { duration: 12000 }); + } +} +// A tip that is shown only once: the first time, and never again, also after a restart. +function showHint(id: HintId, message: string, action?: ToastAction) { + if (hasSeenHint(prefs.hints, id)) return; + prefs.hints = withHint(prefs.hints, id); + try { + savePreferences(localStorage, prefs); + } catch { + /* A tip that comes back is better than one that breaks what the reader was doing. */ + } + toast(message, action); } async function patchBook(id: string, patch: Partial) { - const b = await invoke('update_book', { id, patch }); + const b = await invoke('update_book', { + id, + patch: { ...patch, ...(patch.readingGoal ? { goalDay: localDay() } : {}) }, + }); snapshot.books = snapshot.books.map((x) => (x.id === id ? b : x)); if (current?.id === id) current = { ...b, ...readingPatch }; return b; } +// Several documents in one write of the catalog: all change, or none does. +async function patchBooks(updates: { id: string; patch: Partial }[]) { + const changed = await invoke('update_books', { updates }); + const byId = new Map(changed.map((b) => [b.id, b])); + snapshot.books = snapshot.books.map((b) => byId.get(b.id) ?? b); + if (current && byId.has(current.id)) current = { ...byId.get(current.id)!, ...readingPatch }; + return changed; +} async function toggleFavorite(id: string) { try { const b = snapshot.books.find((x) => x.id === id)!; await patchBook(id, { favorite: !b.favorite }); - renderLibrary(); + keepLibraryView(renderLibrary); + // Under "Favoritos" the card goes away with the star: the focus goes on to the next one. + settleFocus(); updateReaderMeta(); } catch (e) { - fail(e); + fail(e, 'Não foi possível mudar o favorito.'); } } async function restoreBook(id: string) { try { await invoke('restore_book', { id }); await refresh(); + settleFocus(); toast('Documento restaurado.'); } catch (e) { - fail(e); + fail(e, 'Não foi possível restaurar o documento.'); } } -function bookMenu(anchor: HTMLElement, b: Book) { +// After a change that took the control with the focus off the screen, the focus goes to the first +// card, or to the search when there is none. +function settleFocus() { + const active = document.activeElement; + if (active && active !== document.body && active.isConnected) return; + const first = $('#books').querySelector('[data-open], [data-restore]'); + (first ?? $('#library-search')).focus({ preventScroll: true }); +} +// The options of a document, from the button of its card, the reader, or a right click. +// A document in Removidos has one: restore it. In the reader the menu is the one of the document that +// is open: what does something to the reading (Ler do início, Girar página) comes first, then what +// is of the document, then the preferences and the guide. "Marcar como concluído" is not in it, for +// the header has Concluir leitura; the card of the library keeps it. +function bookMenu( + anchor: HTMLElement, + b: Book, + options: Pick = {}, +) { + if (!options.at && anchor.getAttribute('aria-expanded') === 'true') { + closePopover(true); + return; + } + const removed = snapshot.trash.some((x) => x.id === b.id); + // In the reader the document is already open: "Abrir documento" would only load it again. + const reading = current?.id === b.id; + const goal = ``, + restart = ``, + rename = ``, + tags = ``, + exportCopy = ``, + exportNotesItem = ``, + remove = ``; + // The keys are shown with the action, as the other shortcuts are: R only while the keys of one + // letter are on, and F1 always. + const rotate = ``, + preferencesItem = ``, + help = ``; popover( anchor, - `
`, + removed + ? `
` + : reading + ? `${restart}${rotate}
${goal}${rename}${tags}${exportCopy}${exportNotesItem}
${preferencesItem}${help}
${remove}` + : `${goal}${restart}${rename}${tags}${exportCopy}${exportNotesItem}
${remove}`, + { ...options, menu: true, label: `Opções de ${b.title}` }, ); $('#popover') .querySelectorAll('[data-action]') .forEach( (el) => (el.onclick = async () => { - closePopover(); + // The focus goes back to the card or the button, so that a dialog returns it there. + closePopover(true); try { switch (el.dataset.action) { + case 'restore': + await restoreBook(b.id); + break; + case 'purge': + await purgeBooks([b.id]); + break; + case 'tags': + editTags([b]); + break; case 'open': await openBook(b); break; case 'rename': renameBook(b); break; + case 'goal': + await goals(b.id); + break; + case 'restart': + if (current?.id === b.id) restartReading(); + else await openBook({ ...b, page: 1, top: null, left: null }); + break; case 'finish': - await patchBook(b.id, { finished: !b.finished }); - renderLibrary(); - updateReaderMeta(); + await finishBook(b.id); break; case 'export': await exportBook(b); break; + case 'export-notes': + await exportNotes(b); + break; + case 'rotate': + rotateWithUndo(); + break; + case 'preferences': + preferences(); + break; + case 'help': + showHelp(); + break; case 'remove': if (current?.id === b.id) await closeReader(); await invoke('remove_book', { id: b.id }); await refresh(); - toast('PDF movido para Removidos.', { + settleFocus(); + // Each removal keeps its own "Desfazer", so the message says which document it is. + toast(`${shortTitle(b.title)} movido para Removidos.`, { label: 'Desfazer', run: () => restoreBook(b.id), }); } } catch (e) { - fail(e); + fail(e, menuFailures[el.dataset.action ?? '']); } }), ); } +// What each action of the menu of a document says when it fails. +const menuFailures: Record = { + open: 'Não foi possível abrir o documento.', + goal: 'Não foi possível abrir a meta de leitura.', + restart: 'Não foi possível ler do início.', + finish: FAILED.finish, + export: 'Não foi possível exportar a cópia.', + 'export-notes': FAILED.exportNotes, + remove: 'Não foi possível remover o documento.', +}; +const shortTitle = (title: string) => `“${title.length > 40 ? `${title.slice(0, 39)}…` : title}”`; function renameBook(b: Book) { const d = modal( - `

Renomear documento

${ib('close-rename', 'Fechar', 'close', 'data-close')}

O nome na biblioteca muda. O arquivo original permanece intacto.

`, + `

Renomear documento

${ib('close-rename', 'Fechar', 'close', 'data-close')}

O nome na biblioteca muda. O arquivo original permanece intacto.

`, ); - $('#rename-save').onclick = async () => { + const input = $('#rename-input'), + save = $('#rename-save'); + // A title with nothing in it is not sent: the button waits, and the field says why. + const filled = () => { + const ok = !!input.value.trim(); + save.disabled = !ok; + $('#rename-hint').textContent = ok ? '' : 'Escreva um título.'; + return ok; + }; + // What is typed first replaces the title, as when a file is renamed in the Explorer. + input.select(); + input.oninput = filled; + input.onkeydown = (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + save.click(); + }; + save.onclick = async () => { + if (!filled()) return; + save.disabled = true; try { - await patchBook(b.id, { title: $('#rename-input').value }); - renderLibrary(); - updateReaderMeta(); + await patchBook(b.id, { title: input.value }); + // The dialog closes first: it gives the focus back to the button of the card, and the list is + // drawn again with the focus there. d.close(); + keepLibraryView(renderLibrary); + updateReaderMeta(); } catch (e) { - fail(e); + filled(); + fail(e, 'Não foi possível renomear o documento.'); } }; } +// The selection of documents. Ctrl + click and the box of a card tick or untick one; Shift + click +// ticks the range up to it, in the order on screen. Nothing here draws the list again. +const visibleOrder = () => + [...document.querySelectorAll('#books .book-card')].map( + (card) => card.dataset.book!, + ); +function syncSelection() { + updateSelection({ snapshot, filter, selected }); +} +function selectCard(id: string, mode: 'toggle' | 'range') { + const order = visibleOrder(); + if (mode === 'range') { + if (anchor === null || !order.includes(anchor)) anchor = id; + for (const x of rangeIds(order, anchor, id)) selected.add(x); + } else { + if (!selected.delete(id)) selected.add(id); + anchor = id; + } + syncSelection(); +} +function selectAll() { + for (const id of visibleOrder()) selected.add(id); + syncSelection(); +} +// Says whether there was a selection to clear. +function clearSelection() { + if (!selected.size) return false; + selected.clear(); + anchor = null; + syncSelection(); + return true; +} +const chosenBooks = () => + (filter === 'trash' ? snapshot.trash : snapshot.books).filter((b) => selected.has(b.id)); +async function runBatch(work: () => Promise, context: string) { + if (batching) return; + batching = true; + try { + await work(); + } catch (e) { + fail(e, context); + } finally { + batching = false; + } +} +// The same kind of change for several documents, in one write, with a way back that puts each +// document as it was. +async function changeMany(books: Book[], change: (b: Book) => Partial, message: string) { + const updates = books.map((b) => ({ id: b.id, patch: change(b) })); + const undo = books.map((b) => ({ + id: b.id, + patch: Object.fromEntries(Object.keys(change(b)).map((key) => [key, b[key as keyof Book]])), + })); + await patchBooks(updates); + keepLibraryView(renderLibrary); + toast(message, { + label: 'Desfazer', + run: () => + void runBatch(async () => { + await patchBooks(undo); + keepLibraryView(renderLibrary); + }, FAILED.undo), + }); +} +function favoriteSelected() { + return runBatch(async () => { + const books = chosenBooks(), + n = books.length; + if (!n) return; + const favorite = !books.every((b) => b.favorite); + await changeMany( + books, + () => ({ favorite }), + favorite + ? `${documents(n)} ${n === 1 ? 'favoritado' : 'favoritados'}.` + : `${documents(n)} ${n === 1 ? 'removido' : 'removidos'} dos favoritos.`, + ); + }, 'Não foi possível atualizar os favoritos.'); +} +function finishSelected() { + return runBatch(async () => { + const books = chosenBooks(), + n = books.length; + if (!n) return; + const finished = !books.every((b) => b.finished); + await changeMany( + books, + () => ({ finished }), + `${documents(n)} ${n === 1 ? 'marcado' : 'marcados'} como ${finished ? '' : 'não '}concluído${n === 1 ? '' : 's'}.`, + ); + }, 'Não foi possível atualizar a conclusão dos documentos.'); +} +function removeSelected() { + return runBatch(async () => { + const ids = chosenBooks().map((b) => b.id); + if (!ids.length) return; + await invoke('remove_books', { ids }); + selected.clear(); + anchor = null; + await refresh(); + settleFocus(); + toast(`${documents(ids.length)} ${ids.length === 1 ? 'movido' : 'movidos'} para Removidos.`, { + label: 'Desfazer', + run: () => void restoreBooks(ids), + }); + }, 'Não foi possível remover os documentos.'); +} +function restoreBooks(ids: string[]) { + return runBatch(async () => { + if (!ids.length) return; + await invoke('restore_books', { ids }); + for (const id of ids) selected.delete(id); + anchor = null; + await refresh(); + settleFocus(); + toast(`${documents(ids.length)} ${ids.length === 1 ? 'restaurado' : 'restaurados'}.`); + }, 'Não foi possível restaurar os documentos.'); +} +// Deletes documents of Removidos for good, after asking. The result says how much room it freed, +// and tells the files that could not be deleted: those stay in the folder, and the documents are +// out of the catalog anyway. +async function purgeBooks(ids: string[], emptying = false) { + const books = ids + .map((id) => snapshot.trash.find((b) => b.id === id)) + .filter((b): b is Book => !!b); + if (!books.length || batching) return; + if (!(await confirmPurge(books, emptying))) return; + await runBatch(async () => { + const result = await invoke('purge_books', { ids: books.map((b) => b.id) }); + for (const book of books) selected.delete(book.id); + anchor = null; + await refresh(); + settleFocus(); + toast( + `${documents(result.purged)} ${result.purged === 1 ? 'excluído' : 'excluídos'} definitivamente.${result.freed ? ` ${size(result.freed)} liberados.` : ''}`, + ); + if (result.failed.length) + toast( + result.failed.length === 1 + ? 'Um arquivo não pôde ser excluído e continua na pasta da biblioteca.' + : `${result.failed.length} arquivos não puderam ser excluídos e continuam na pasta da biblioteca.`, + undefined, + { kind: 'error', detail: result.failed.join('\n') }, + ); + }, 'Não foi possível excluir os documentos.'); +} +// The tags of one document (the whole list is what is saved), or one or more tags added to several. +function editTags(books: Book[]) { + if (!books.length) return; + showTags({ + books, + known: tagCounts(snapshot.books), + save: async (tags) => { + if (books.length === 1) { + await patchBook(books[0].id, { tags }); + keepLibraryView(renderLibrary); + toast('Etiquetas salvas.'); + return null; + } + const updates: { id: string; patch: Partial }[] = []; + for (const book of books) { + let next = tagsOf(book); + for (const tag of tags) { + const added = addTag(next, tag); + if (added.problem === 'full') + return `${shortTitle(book.title)} já tem ${MAX_TAGS} etiquetas.`; + if (!added.problem) next = added.tags; + } + if (next.length !== tagsOf(book).length) + updates.push({ id: book.id, patch: { tags: next } }); + } + if (!updates.length) return 'Os documentos já têm essas etiquetas.'; + const undo = updates.map(({ id }) => ({ + id, + patch: { tags: tagsOf(books.find((b) => b.id === id)!) }, + })); + await patchBooks(updates); + keepLibraryView(renderLibrary); + toast( + `${tags.length === 1 ? 'Etiqueta adicionada' : 'Etiquetas adicionadas'} a ${documents(updates.length)}.`, + { + label: 'Desfazer', + run: () => + void runBatch(async () => { + await patchBooks(undo); + keepLibraryView(renderLibrary); + }, FAILED.undo), + }, + ); + return null; + }, + }); +} +function chooseFilter(next: string) { + // What is ticked belongs to what is on screen: another filter starts with nothing ticked. + const cleared = next !== filter && selected.size > 0; + if (next !== filter) { + selected.clear(); + anchor = null; + } + filter = next; + renderLibrary(); + // The bar goes away with the ticks: say why, so that the loss is not silent. + if (cleared) toast('Seleção limpa ao trocar de filtro.'); +} +// The way to a file that an export just made: the Explorer opens on its folder, with it chosen. +const showFile = (path: string): ToastAction => ({ + label: 'Abrir pasta', + run: () => void invoke('reveal_file', { path }).catch(failing('Não foi possível abrir a pasta.')), +}); async function exportBook(b: Book) { const destination = await save({ title: 'Exportar cópia do PDF', @@ -162,43 +754,184 @@ async function exportBook(b: Book) { }); if (destination) { await invoke('export_pdf', { id: b.id, destination }); - toast('Cópia exportada.'); + toast('Cópia exportada.', showFile(destination)); } } -async function importFiles(paths?: string[]) { - if (busy) return; +// The notes and highlights of a document as a Markdown file, made by the backend from the catalog. +// The dates are the reader's own: the backend gets the distance from UTC, in minutes. +async function exportNotes(b: Book) { + if (!b.notes?.length) { + toast('Este documento ainda não tem notas para exportar.'); + return; + } + const stem = + b.title + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_') + .trim() + .slice(0, 100) || 'Notas'; + const destination = await save({ + title: 'Exportar notas', + defaultPath: `${stem} - notas.md`, + filters: [{ name: 'Markdown', extensions: ['md'] }], + }); + if (!destination) return; + await invoke('export_notes', { + id: b.id, + destination, + offsetMinutes: -new Date().getTimezoneOffset(), + }); + toast('Notas exportadas.', showFile(destination)); +} +// Files by the dialog (or the ones dropped on the window), or one folder with `folder`. The +// backend turns folders, dropped or chosen, into the PDFs inside them. With `launched`, the files +// came from outside (see `openLaunched`): their first book opens, and a single file has no summary. +// While a copy runs there is one import at a time: what is dropped waits for its turn (see +// `queueDropped`), and Ctrl + O, which has nothing to wait with, says why it did nothing. +async function importFiles(paths?: string[], folder = false, launched = false) { + if (busy) { + if (!paths) toast('Aguarde a cópia dos PDFs terminar.'); + else if (!launched && paths.length) queueDropped(paths); + return; + } + let result: ImportResult | undefined, + count = 0; try { if (!paths) { - const selected = await open({ - title: 'Adicionar PDFs à sua biblioteca', - multiple: true, - filters: [{ name: 'Documentos PDF', extensions: ['pdf'] }], - }); + const selected = await open( + folder + ? { title: 'Adicionar uma pasta de PDFs à sua biblioteca', directory: true } + : { + title: 'Adicionar PDFs à sua biblioteca', + multiple: true, + filters: [{ name: 'Documentos PDF', extensions: ['pdf'] }], + }, + ); if (!selected) return; paths = Array.isArray(selected) ? selected : [selected]; } if (!paths.length) return; + count = paths.length; busy = true; - $('#import-main').setAttribute('disabled', ''); - toast(`Copiando ${paths.length} PDF${paths.length === 1 ? '' : 's'}…`); - const result = await invoke<{ added: Book[]; duplicates: number; errors: string[] }>( - 'import_pdfs', - { paths }, - ); + // The progress is in the button, not in a message for each file. + showImportProgress(0, paths.length); + result = await invoke('import_pdfs', { paths }); await refresh(); - toast( - `${result.added.length} PDF${result.added.length === 1 ? ' adicionado' : 's adicionados'}${result.duplicates ? ` · ${result.duplicates} já existente${result.duplicates === 1 ? '' : 's'}` : ''}`, - ); + // The covers, the page counts and the authors of what came in are read one by one from here. + enrichment.add(waitingForMetadata(result.added)); + if (!launched || count > 1) importSummary(result); if (result.errors.length) modal( - `

Alguns arquivos não foram adicionados

${ib('close-errors', 'Fechar', 'close', 'data-close')}

Os demais arquivos foram importados normalmente.

    ${result.errors.map((e) => `
  • ${esc(e)}
  • `).join('')}
`, + `

Alguns arquivos não foram adicionados

${ib('close-errors', 'Fechar', 'close', 'data-close')}

Os demais arquivos foram importados normalmente.

    ${result.errors.map(importProblem).join('')}
`, ); } catch (e) { - fail(e); + fail(e, 'Não foi possível adicionar os PDFs.'); } finally { busy = false; - $('#import-main').removeAttribute('disabled'); + hideImportProgress(); } + // Opening a book is not part of the import: the window can be closed, and another import can + // start, while it loads. + if (launched && result) await openImported(result); + // What came from outside, or was dropped on the window, while this import ran was waiting for it. + void openLaunched(); +} +// The PDFs that came from outside the window: a double click or "Abrir com" in the Explorer, or a +// second launch of the app. The backend keeps their paths until they are asked for, so none is +// lost while the window starts. They go through the normal import, and then the first one opens, +// even over a book that is already open. They wait for an import that is running, and for each +// other: `launchQueue` is what has been asked for and not yet imported. +const launchQueue: string[] = []; +// The files and folders dropped on the window while a copy ran. They wait in the same way, and are +// imported as a normal import (with its summary, and no book opened) once the copy ends, after what +// the Explorer asked for. The notice that says how many are waiting is renewed with each drop. +const dropQueue: string[] = []; +let launching = false, + queuedNotice: (() => void) | null = null; +function queueDropped(paths: string[]) { + dropQueue.push(...paths); + queuedNotice?.(); + queuedNotice = toast( + `Na fila: ${dropQueue.length} ${dropQueue.length === 1 ? 'item' : 'itens'}.`, + ); +} +async function takeLaunched() { + launchQueue.push(...(await invoke('take_pending_files'))); + await openLaunched(); +} +async function openLaunched() { + if (launching) return; + launching = true; + try { + while ((launchQueue.length || dropQueue.length) && !busy) { + if (launchQueue.length) await importFiles(launchQueue.splice(0), false, true); + else { + queuedNotice?.(); + queuedNotice = null; + await importFiles(dropQueue.splice(0)); + } + } + } finally { + launching = false; + } +} +// The book of the first file of an import: one that came in or, when none did, one that was +// already there. A book that is still loading is waited for, and one that is open stays as it is. +async function openImported({ added, existing }: ImportResult) { + const id = (added[0] ?? existing[0])?.id; + while (loading) await new Promise((resolve) => setTimeout(resolve, 100)); + const book = snapshot.books.find((b) => b.id === id); + if (!book || current?.id === book.id) return; + await openBook(book, true).catch(failing(FAILED.openPdf)); +} +function importFolder() { + return importFiles(undefined, true); +} +// One message for the whole import. A single book that came in can be opened from it, and a +// single one that was already there can be shown. +function importSummary({ added, duplicates, existing, errors }: ImportResult) { + if (!added.length && !duplicates) { + // Nothing came in. A file that failed is told about in the list of errors; a folder with no + // PDF in it has no other way to say so. + if (!errors.length) toast('Nenhum PDF encontrado.'); + } else if (!added.length) { + if (existing.length === 1) { + const id = existing[0].id; + toast(`${shortTitle(existing[0].title)} já estava na biblioteca.`, { + label: 'Mostrar', + run: () => void showBook(id).catch(failing(FAILED.back)), + }); + } else toast(`${duplicates} PDFs já estavam na biblioteca.`); + } else { + const text = `${added.length} PDF${added.length === 1 ? ' adicionado' : 's adicionados'}${duplicates ? ` · ${duplicates} já existente${duplicates === 1 ? '' : 's'}` : ''}.`; + if (added.length === 1) { + const book = added[0]; + toast(text, { + label: 'Abrir', + run: () => + void openBook(snapshot.books.find((b) => b.id === book.id) ?? book).catch( + failing(FAILED.openPdf), + ), + }); + } else toast(text); + } +} +// Back to the library as it is with nothing typed and nothing filtered, at a card. +async function showBook(id: string) { + if (current) await closeReader(); + query = ''; + $('#library-search').value = ''; + filter = 'all'; + selected.clear(); + anchor = null; + renderLibrary(); + revealBook(id); +} +// A line of the import's errors is "file.pdf: reason". The reason is said in plain words; its +// original text stays only when nothing is known of it. +function importProblem(line: string) { + const at = line.indexOf(': '); + const { message, detail } = describeError(at > 0 ? line.slice(at + 2) : line); + return `
  • ${at > 0 ? `${esc(line.slice(0, at))}: ` : ''}${esc(message)}${message === GENERIC_ERROR && detail ? ` (${esc(detail)})` : ''}
  • `; } function queueReading(patch: Partial) { @@ -206,10 +939,21 @@ function queueReading(patch: Partial) { readingPatch = { ...readingPatch, ...patch }; current = { ...current, ...patch }; clearTimeout(saveTimer); - $('#save-status').textContent = 'Salvando posição…'; - saveTimer = setTimeout(() => void flushReading(), 450); + saveTimer = setTimeout(() => void flushReading(true).catch(() => {}), 450); } -async function flushReading() { +// The header of the reader says of saving only what the reader needs to know: nothing when it goes +// well, "Salvando…" when it is slow, and the failure for as long as it lasts. +function showSaveStatus(text: string) { + const status = $('#save-status'); + status.textContent = text; + status.hidden = !text; +} +// Saves what the reading changed. A failure is told to the reader once (the reason, and that the +// position was not saved) and the saving keeps trying, less often each time, until it works. Those +// tries (`quiet`) say nothing more; whoever asks to save (closing, a dialog) is told again. The +// error that comes out was already reported: see `ReportedError`. +let saveFailures = 0; +async function flushReading(quiet = false) { clearTimeout(saveTimer); if (!current || !Object.keys(readingPatch).length) return saveChain; const id = current.id, @@ -218,42 +962,90 @@ async function flushReading() { saveChain = saveChain .catch(() => {}) .then(async () => { - await patchBook(id, patch); - $('#save-status').textContent = 'Posição salva automaticamente'; + const slow = setTimeout(() => showSaveStatus(SAVE_STATUS.saving), SLOW_SAVE); + try { + await patchBook(id, patch); + } finally { + clearTimeout(slow); + } + saveFailures = 0; + showSaveStatus(''); }) .catch((e) => { - $('#save-status').textContent = 'Não foi possível salvar'; - if (current?.id === id) readingPatch = { ...patch, ...readingPatch }; - fail(e); - throw e; + const first = saveFailures++ === 0; + showSaveStatus(SAVE_STATUS.retrying); + if (current?.id === id) { + readingPatch = { ...patch, ...readingPatch }; + saveTimer = setTimeout( + () => void flushReading(true).catch(() => {}), + Math.min(30000, 2000 * 2 ** (saveFailures - 1)), + ); + } + if (!quiet || first) fail(e, 'Não foi possível salvar a posição de leitura.'); + throw new ReportedError(); }); return saveChain; } -async function openBook(book: Book) { +async function openBook(book: Book, keepNotices = false) { if (!book || loading) return; closePopover(); + // The messages of the library do not stay over the page. The ones of a book that came from outside + // (the summary of an import) are the exception: nobody has seen them yet. + if (!keepNotices) dismissNotices(); + clearSelection(); + jumpPill.hide(); + selectionBar.hide(); + notesState.query = ''; + // A book that is open has the memory and the processor to itself. + enrichment.pause(); if (current) await closeReader(); loading = true; + passwordDeclined = false; + resetTracking(); + finishPrompted = false; current = { ...book }; resumePosition = prefs.resume && book.lastOpened > 0; $('#library').hidden = true; $('#reader').hidden = false; $('#reader-loading').hidden = false; + showSaveStatus(''); + // The card that had the focus is gone from the screen: the way out of the loading is where it goes. + $('#cancel-loading').focus({ preventScroll: true }); updateReaderMeta(); applyPrefs(); + outlineState.folds = new Map(); + renderSidebar(); try { await getCurrentWindow().setTitle(`${book.title} · Fólio`); const { Reader } = await import('./reader'); reader = new Reader({ mode: prefs.readingMode, + spread: prefs.spread, + joinPages: prefs.joinPages, + chapterBreaks: prefs.chapterBreaks, onViewport: updateReaderMeta, + onOutline: () => renderSidebar('outline'), + // A jump is not a step of the reading: the pace starts over on the page it lands on. + // The goals already ignore the pages in between, which are never on screen. + onJump: (offer) => { + pace.reset(); + jumpPill.show(offer); + }, + // A highlight (at the pointer) or the mark of a note (a button) was clicked: its options. + onNote: (id, anchor, at) => { + const note = current?.notes.find((n) => n.id === id); + if (!note) return; + if (!at && anchor.getAttribute('aria-expanded') === 'true') return closePopover(true); + selectionBar.hide(); + openNoteMenu(anchor, note, at ? { at } : {}); + }, + onSelect: () => selectionBar.follow(), container: $('#viewer-container'), viewer: $('#pdf-viewer'), onPage: (page) => { if (current) { current.page = page; updateReaderMeta(); - renderSidebar(); } }, onLocation: (location) => { @@ -274,13 +1066,15 @@ async function openBook(book: Book) { onPassword: async (incorrect) => new Promise((resolve) => { const d = modal( - `

    PDF protegido por senha

    ${ib('close-password', 'Cancelar', 'close', 'data-close')}

    ${incorrect ? 'Senha incorreta. Tente novamente.' : 'Digite a senha para abrir o documento. Ela não será armazenada.'}

    `, + `

    PDF protegido por senha

    ${ib('close-password', 'Cancelar', 'close', 'data-close')}

    ${incorrect ? 'Senha incorreta. Tente novamente.' : 'Digite a senha para abrir o documento. Ela não será armazenada.'}

    `, ); let sent = false; d.addEventListener( 'close', () => { - if (!sent) resolve(null); + if (sent) return; + passwordDeclined = true; + resolve(null); }, { once: true }, ); @@ -335,7 +1129,8 @@ async function openBook(book: Book) { } catch (e) { if (current) { await closeReader(); - toast(`Não foi possível abrir este PDF: ${String(e)}`); + // Giving up on the password leaves the PDF closed, and that is all there is to say. + if (!passwordDeclined) fail(e, FAILED.openPdf); } } finally { loading = false; @@ -351,86 +1146,287 @@ function updateReaderMeta() { if (document.activeElement !== pageInput) pageInput.value = String(current.page); $('#page-total').textContent = `/ ${current.pages || '…'}`; $('#page-number').max = String(current.pages || 1); + // The name stays and `aria-pressed` tells the state, as on the card of the library; the tooltip + // says what the click does now. $('#reader-favorite').classList.toggle('selected', current.favorite); $('#reader-favorite').setAttribute('aria-pressed', String(current.favorite)); + $('#reader-favorite').title = current.favorite ? 'Remover dos favoritos' : 'Favoritar documento'; $('#bookmark').classList.toggle('selected', current.bookmarks.includes(current.page)); $('#bookmark').setAttribute('aria-pressed', String(current.bookmarks.includes(current.page))); const kindle = prefs.readingMode === 'kindle', screen = reader?.screen; - const progress = - kindle && screen ? screen.progress : Math.round((current.page / current.pages) * 100); + // The same calculation as the library. Only reaching the document's end uses the scroll. + const atEnd = !loading && !!screen?.atEnd; + const position = { + page: current.page, + pages: current.pages, + finished: current.finished, + atEnd, + }; + // With two pages side by side, a step moves by a pair: the ends are those of the pair, and the + // footer names both of the pages that are on the screen. + const shown = spreadPages(current.page, current.pages, kindle ? 'none' : prefs.spread); + const percent = readingProgress(position); $('#reading-progress').textContent = current.pages - ? `${progress}% · Página ${current.page} de ${current.pages}` + ? `${percent}% · ${shown.length > 1 ? `Páginas ${shown[0]}–${shown.at(-1)}` : `Página ${current.page}`} de ${current.pages}` : ''; - $('#reading-label').textContent = kindle - ? 'Kindle · ← → ou Espaço' - : current.finished - ? 'Leitura concluída' - : `${size(current.size)} · Cópia local`; + // The line of the full screen, where the footer is away: the same percentage. + $('#reading-line').style.setProperty('--read', `${current.pages ? percent : 0}%`); + const left = remainingTime({ + ...position, + chapters: reader?.chapterStarts, + secondsPerPage: secondsPerPage(prefs.pace, current.id), + }); + const estimate = left + ? `≈ ${left.chapter === null ? '' : `${formatDuration(left.chapter)} no capítulo · `}${formatDuration(left.book)} no documento` + : ''; + // Scrolling calls this constantly; touch the DOM only when the text changes. + if ($('#reading-time').dataset.estimate !== estimate) { + $('#reading-time').dataset.estimate = estimate; + $('#reading-time').innerHTML = estimate; + $('#reading-time').hidden = !left; + } + // The name stays; the state is in `aria-pressed` and in the colour, like the Kindle button. + const finish = $('#finish-reading'); + finish.setAttribute('aria-pressed', String(current.finished)); + finish.classList.toggle('selected', current.finished); + // The tip is for whoever got to the end by reading, not for a document that fits on the screen. + if (atEnd && screen?.scrolls && !current.finished && !finishPrompted) { + finishPrompted = true; + const id = current.id; + showHint('finish', 'Chegou ao fim? Você pode concluir a leitura.', { + label: 'Concluir', + run: () => void finishBook(id).catch(failing(FAILED.finish)), + }); + } + updateGoals(); + const paired = !kindle && prefs.spread !== 'none'; $('#previous').toggleAttribute( 'disabled', - kindle ? !screen || screen.atStart : current.page <= 1, + kindle ? !screen || screen.atStart : (shown[0] ?? 1) <= 1, ); $('#next').toggleAttribute( 'disabled', - kindle ? !screen || screen.atEnd : current.page >= current.pages, + kindle ? !screen || screen.atEnd : (shown.at(-1) ?? current.page) >= current.pages, ); for (const [id, label] of [ - ['previous', kindle ? 'Tela anterior' : 'Página anterior'], - ['next', kindle ? 'Próxima tela' : 'Próxima página'], + ['previous', kindle ? 'Tela anterior' : paired ? 'Páginas anteriores' : 'Página anterior'], + ['next', kindle ? 'Próxima tela' : paired ? 'Próximas páginas' : 'Próxima página'], ]) { $(`#${id}`).setAttribute('aria-label', label); $(`#${id}`).title = label; } + syncSidebar(); } function setReadingMode(mode: Preferences['readingMode']) { prefs.readingMode = mode; applyPrefs(); } +function resetTracking() { + activity.reset(); + pace.reset(); +} +function goalBooks() { + return snapshot.books.map((b) => (current?.id === b.id ? current : b)); +} +// Every document that has days of reading: those of the library, those in Removidos and, as books +// with nothing but their days, those deleted for good. The general goal and the history count all +// of them, today as in the days before. +function countedBooks() { + return [...goalBooks(), ...snapshot.trash, ...snapshot.archived.map(archivedAsBook)]; +} +function updateGoals() { + const global = prefs.dailyGoal; + const done = global ? globalCount(countedBooks(), global) : 0; + $('#library-goal-text').textContent = global + ? `Hoje · ${goalLabel(global, done)}` + : 'Definir meta diária'; + // A thin bar under the text says how far the day is, so that the button reads as a gauge. + const track = $('#library-goal').querySelector('.goal-track')!; + track.hidden = !global; + if (global) + track.style.setProperty( + '--goal', + `${Math.min(100, Math.round((done / global.target) * 100))}%`, + ); + // The reader shows the goal of the document when it has one, and the general goal when it has not. + const progress = goalProgress(current ?? undefined, global, countedBooks()); + $('#reader-goal').textContent = + current && progress.goal ? `Hoje · ${goalLabel(progress.goal, progress.count)}` : 'Meta diária'; + reader?.setGoalTracking(!!current && !current.finished); +} +// `back`, when the history comes from the goals, takes the reader back to them. +async function history(id?: string, back?: () => void) { + await flushReading(); + resetTracking(); + // The days of a document deleted for good are still counted: its record has no file, only them. + showHistory(prefs, countedBooks(), id, new Set(snapshot.archived.map((a) => a.id)), back); +} +async function goals(id?: string) { + await flushReading(); + resetTracking(); + showGoals( + prefs, + { + books: goalBooks, + counted: countedBooks, + save: patchBook, + history: (scope) => + void history(scope, () => void goals(scope).catch(failing(FAILED.goals))).catch( + failing(FAILED.history), + ), + apply: () => { + applyPrefs(); + renderLibrary(); + }, + }, + id, + ); +} +async function finishBook(id: string) { + await flushReading(); + const book = snapshot.books.find((b) => b.id === id); + if (!book) return; + const finished = !book.finished; + await patchBook(id, { finished }); + resetTracking(); + keepLibraryView(renderLibrary); + settleFocus(); + updateReaderMeta(); + toast( + finished + ? 'Leitura concluída. Ela está em Concluídos.' + : 'Documento marcado como não concluído.', + { + label: 'Desfazer', + run: () => { + void patchBook(id, { finished: !finished }) + .then(() => { + keepLibraryView(renderLibrary); + updateReaderMeta(); + }) + .catch(failing(FAILED.undo)); + }, + }, + ); +} +setInterval(() => { + const day = localDay(); + if (day !== goalDay) { + goalDay = day; + resetTracking(); + updateGoals(); + } + if (!current || !reader || loading || current.finished) { + resetTracking(); + return; + } + // One definition of "reading", shared by the goals and the pace estimate. + const active = + document.hasFocus() && + !document.hidden && + !$('#dialog').open && + $('#find-bar').hidden && + $('#popover').hidden; + const now = performance.now(); + // One page, or the pair on screen in a two-page layout. A pair is one step of two pages. + const shown = reader.screenPages; + const measured = pace.sample({ book: current.id, page: shown.first, active }, now); + if (measured !== null) { + prefs.pace = recordPace(prefs.pace, current.id, measured, Date.now()); + try { + savePreferences(localStorage, prefs); + } catch { + /* The pace is a convenience. Counting reading goals must not depend on it. */ + } + updateReaderMeta(); + } + const credit = activity.sample( + { + book: current.id, + day, + page: shown.first, + last: shown.last, + active, + atEnd: reader.screen.atEnd, + chapters: reader.chapterStarts, + }, + now, + ); + const record = dayRecord(current, day); + const next = withCredit(record, credit); + if (next !== record) { + queueReading({ readingDays: withDay(current, day, next) }); + updateGoals(); + } +}, 1000); + async function closeReader() { if (!current) return; + resetTracking(); + await chrome.exit(); + closePopover(); + jumpPill.hide(); + selectionBar.hide(); await flushReading(); const old = reader; reader = null; current = null; loading = false; + // The side bar keeps its state for the next book. Its drawings stop with this one. + disposeSidebar(); + $('#sidebar-content').innerHTML = ''; + renderedTab = null; await old?.destroy(); $('#reader').hidden = true; $('#library').hidden = false; - document.body.classList.remove('focus-mode'); - $('#exit-focus').hidden = true; $('#find-bar').hidden = true; - $('#reader-sidebar').hidden = true; await getCurrentWindow().setTitle('Fólio'); renderLibrary(); $('#library-search').focus(); + enrichment.resume(); +} +// Alt + ← and Alt + →, the side buttons of the mouse and the pill: one step along the positions +// that the jumps of this document left. With nowhere to go the pill goes, and a message says why. +// The message only says that there is no position: leaving the document is another key's business. +function stepJump(direction: 'back' | 'forward') { + if (!reader || loading) return; + if (direction === 'back' ? reader.back() : reader.forward()) return; + jumpPill.hide(); + toast(direction === 'back' ? 'Nenhuma posição anterior.' : 'Nenhuma posição à frente.'); } function restartReading() { if (!reader || !current) return; - reader.goTo(1); + reader.jumpTo(1); queueReading({ page: 1 }); updateReaderMeta(); } -function toggleFocus() { - const active = document.body.classList.toggle('focus-mode'); - $('#exit-focus').hidden = !active; - reader?.resize(); -} function showFind() { if (!current) return; + closePopover(); $('#find-bar').hidden = false; + chrome.showTop(); $('#find-input').focus(); reader?.resize(); } function searchPdf(again = false, back = false) { reader?.find($('#find-input').value, again, back); } -function toggleSidebar(tab?: string) { - if (tab) sideTab = tab; - const s = $('#reader-sidebar'); - s.hidden = tab ? false : !s.hidden; - renderSidebar(); +function toggleSidebar(tab?: SidebarTab) { + if (!current) return; + const focused = $('#reader-sidebar').contains(document.activeElement); + if (tab) { + prefs.sidebarTab = tab; + prefs.sidebarOpen = true; + } else prefs.sidebarOpen = !prefs.sidebarOpen; + applyPrefs(); + if (prefs.sidebarOpen) renderSidebar(); + else { + disposeSidebar(); + // A hidden bar cannot keep the focus; the reading surface takes it. + if (focused) $('#viewer-container').focus({ preventScroll: true }); + } reader?.resize(); } async function toggleBookmark() { @@ -443,115 +1439,391 @@ async function toggleBookmark() { : [...current.bookmarks, p].sort((a, b) => a - b), }); updateReaderMeta(); - renderSidebar(); + renderSidebar('bookmarks'); +} +// Takes one mark off the list of the Marcas tab, with a way back that puts it where it was. The +// button that was pressed goes away with the old drawing, so the focus goes to the same place of the +// list (the next mark; the one before, if it was the last) and to the tab when no mark is left. +async function removeBookmark(page: number) { + const open = current; + if (!open) return; + const id = open.id, + fromList = $('#sidebar-content').contains(document.activeElement), + at = open.bookmarks.indexOf(page); + await flushReading(); + if (current?.id !== id) return; + await patchBook(id, { bookmarks: current.bookmarks.filter((n) => n !== page) }); + updateReaderMeta(); + renderSidebar('bookmarks'); + if (fromList) { + const buttons = [...$('#sidebar-content').querySelectorAll('.bookmark-remove')]; + (buttons[Math.min(at, buttons.length - 1)] ?? $('#tab-bookmarks')).focus(); + } + toast(`Marca da página ${page} removida.`, { + label: 'Desfazer', + run: () => void restoreBookmark(id, page).catch(failing(FAILED.undo)), + }); +} +// Puts a mark back, in its place in the order of the pages. The document may have been closed since. +async function restoreBookmark(id: string, page: number) { + if (current?.id === id) await flushReading(); + const book = current?.id === id ? current : snapshot.books.find((b) => b.id === id); + if (!book || book.bookmarks.includes(page)) return; + await patchBook(id, { bookmarks: [...book.bookmarks, page].sort((a, b) => a - b) }); + if (current?.id !== id) return; + updateReaderMeta(); + renderSidebar('bookmarks'); + // The message gave the focus back to a button that the drawing replaced: it goes to the mark. + const lost = !document.activeElement || document.activeElement === document.body; + if (lost && !$('#reader-sidebar').hidden && prefs.sidebarTab === 'bookmarks') + $('#sidebar-content').querySelector(`.bookmark-row[data-page="${page}"]`)?.focus(); +} +function disposeSidebar() { + outlineView?.dispose(); + outlineView = null; + thumbnails?.dispose(); + thumbnails = null; } -function renderSidebar() { - if ($('#reader-sidebar').hidden || !current) return; +// Draws the active tab again. `only` limits it to one tab: changing the bookmarks does not +// rebuild the outline the reader is looking at. +function renderSidebar(only?: SidebarTab) { + if (!current || $('#reader-sidebar').hidden) return; + const tab = prefs.sidebarTab; + if (only && only !== tab) return; const content = $('#sidebar-content'); - document - .querySelectorAll('[data-tab]') - .forEach((b) => b.classList.toggle('active', b.dataset.tab === sideTab)); - if (sideTab === 'outline') { - const items = reader?.outline || []; - const render = (items: any[], depth = 0): string => - items - .map( - (item) => - `${render(item.items || [], depth + 1)}`, - ) - .join(''); - content.innerHTML = items.length - ? render(items) - : `
    ${icon('list')}

    Sem sumário neste PDF

    Use o número da página ou a busca para navegar.

    `; - content - .querySelectorAll('[data-dest]') - .forEach((b) => (b.onclick = () => reader?.destination(JSON.parse(b.dataset.dest!)))); - } else if (sideTab === 'bookmarks') { + const scrolled = renderedTab === tab ? content.scrollTop : 0; + disposeSidebar(); + renderedTab = tab; + shownInSidebar = ''; + const ready = !!reader && !loading; + const waiting = (text: string) => + `

    ${text}

    `; + if (tab === 'outline') { + if (!reader || !ready || !reader.outlineReady) + content.innerHTML = waiting('Abrindo o sumário…'); + else if (!reader.outline.length) + content.innerHTML = `
    ${icon('list')}

    Sem sumário neste PDF

    Use o número da página, as miniaturas ou a busca para navegar.

    `; + else + outlineView = new OutlineView(content, reader.outline, outlineState, (entry) => { + // An item that points nowhere in this document (a broken or external destination) is drawn as + // unavailable; the click says why it does nothing. + if (entry.page === null) toast(NO_DESTINATION); + else reader?.destination(entry.dest); + }); + } else if (tab === 'pages') { + if (!reader || !ready) content.innerHTML = waiting('Abrindo as páginas…'); + else thumbnails = new ThumbnailList(content, reader, (page) => reader?.jumpTo(page)); + } else if (tab === 'bookmarks') { content.innerHTML = current.bookmarks.length ? current.bookmarks .map( (p) => - ``, + `
    `, ) .join('') - : `
    ${icon('bookmark')}

    Guarde uma página

    Clique no marcador da barra ou use Ctrl + B.

    `; + : `
    ${icon('bookmark')}

    Guarde uma página

    Clique em “Marcar esta página” na barra de ferramentas ou use Ctrl + B.

    `; } else { - content.innerHTML = `${current.notes.length ? current.notes.map((n) => `
    ${n.quote ? `
    ${esc(n.quote)}
    ` : ''}

    ${esc(n.text)}

    `).join('') : `
    ${icon('note')}

    Suas ideias, por perto

    Selecione um trecho e adicione uma nota. Tudo fica salvo neste computador.

    `}`; - $('#side-add-note').onclick = () => editNote(); + renderNotes(content, current.notes, notesState, { + add: () => editNote(), + edit: (note) => editNote(note), + menu: (anchor, note) => openNoteMenu(anchor, note), + go: (note) => reader?.jumpToNote(note), + exportNotes: () => current && void exportNotes(current).catch(failing(FAILED.exportNotes)), + }); + } + if (tab === 'bookmarks') { + content + .querySelectorAll('.bookmark-row') + .forEach((b) => (b.onclick = () => reader?.jumpTo(Number(b.dataset.page)))); content - .querySelectorAll('[data-edit-note]') + .querySelectorAll('[data-remove-page]') .forEach( (b) => - (b.onclick = () => editNote(current?.notes.find((n) => n.id === b.dataset.editNote))), + (b.onclick = () => + void removeBookmark(Number(b.dataset.removePage)).catch(failing(FAILED.bookmark))), ); } - content - .querySelectorAll('[data-page]') - .forEach((b) => (b.onclick = () => reader?.goTo(Number(b.dataset.page)))); + content.scrollTop = scrolled; + syncSidebar(); +} +// Follows the reading without drawing the tab again: the section and the pages on screen. +function syncSidebar() { + if (!current || loading || $('#reader-sidebar').hidden) return; + const tab = prefs.sidebarTab; + let key = ''; + if (tab === 'outline' && outlineView) key = `outline:${current.page}`; + else if (tab === 'pages' && thumbnails && reader) { + const shown = reader.screenOf(current.page); + key = `pages:${shown.first}:${shown.last}`; + } else if (tab === 'bookmarks') key = `bookmarks:${current.page}`; + if (!key || key === shownInSidebar) return; + shownInSidebar = key; + if (tab === 'outline') outlineView?.show(current.page); + else if (tab === 'pages' && reader) { + const shown = reader.screenOf(current.page); + thumbnails?.show(shown.first, shown.last); + } else + $('#sidebar-content') + .querySelectorAll('.bookmark-row') + .forEach((row) => { + const on = Number(row.dataset.page) === current?.page; + row.classList.toggle('active', on); + if (on) row.setAttribute('aria-current', 'page'); + else row.removeAttribute('aria-current'); + }); +} +// A change of a note draws the Notas tab again, and the button that had the focus goes away with the +// old drawing. `notesTabFocus` says where the focus is in the tab (null when it is elsewhere, or the +// tab is not on screen), and `backToNotesTab` gives it back once the tab is drawn: to the same +// control of the same note, to the next note when it was deleted, or to the search. +const notesTabFocus = () => + !$('#reader-sidebar').hidden && prefs.sidebarTab === 'notes' + ? notesFocusOf($('#sidebar-content'), document.activeElement) + : null; +function backToNotesTab(saved: NotesFocus | null) { + if (saved && !$('#reader-sidebar').hidden) restoreNotesFocus($('#sidebar-content'), saved); +} +// The notes of the open book change one at a time, each on the notes as they are by then, so that +// two highlights made in quick succession both stay. The pages and the side bar follow. +let notesChain: Promise = Promise.resolve(); +function changeNotes(change: (notes: Note[]) => Note[]) { + const id = current?.id; + if (!id) return Promise.resolve(); + const run = async () => { + await flushReading(); + if (current?.id !== id) return; + await patchBook(id, { notes: change([...current.notes]) }); + if (current?.id !== id) return; + reader?.setNotes(current.notes); + const focus = notesTabFocus(); + renderSidebar('notes'); + backToNotesTab(focus); + }; + const done = notesChain.catch(() => {}).then(run); + notesChain = done; + return done; } -function editNote(existing?: Note) { +// The passage that is selected in the pages, highlighted in `color`. A selection across pages +// becomes one highlight for each page. Says whether there was a selection. +function highlightSelection(color: NoteColor = rememberedColor()) { + const capture = reader?.captureSelection(); + if (!capture || !current || loading) return false; + rememberColor(color); + const now = Date.now(); + const made = capture.pieces.map((piece, i): Note => ({ + id: crypto.randomUUID(), + page: piece.page, + text: '', + quote: piece.quote, + created: now + i, + rects: piece.rects, + color, + })); + selectionBar.hide(); + getSelection()?.removeAllRanges(); + // The highlight shows itself in the page and its card in the Notas tab: no box says it again, and a + // screen reader is told in the quiet region. + void changeNotes((notes) => [...notes, ...made]) + .then(() => + announce( + made.length === 1 ? 'Trecho realçado.' : `Trecho realçado em ${made.length} páginas.`, + ), + ) + .catch(failing('Não foi possível realçar o trecho.')); + return true; +} +// A note on the passage that is selected. Says whether there was a selection. +function annotateSelection() { + const capture = reader?.captureSelection(); + if (!capture || !current || loading) return false; + selectionBar.hide(); + getSelection()?.removeAllRanges(); + editNote(undefined, capture); + return true; +} +async function copySelection() { + const text = getSelection()?.toString().trim() ?? ''; + selectionBar.hide(); + if (!text) return; + const copied = await copyText(text); + getSelection()?.removeAllRanges(); + if (copied) toast('Texto copiado.'); + else toast('Não foi possível copiar o texto.', undefined, { kind: 'error' }); +} +async function copyNote(note: Note) { + const copied = await copyText(note.quote.trim() || note.text.trim()); + if (copied) toast('Texto copiado.'); + else toast('Não foi possível copiar o texto.', undefined, { kind: 'error' }); +} +// Deletes a note or a highlight, with a way back that puts it where it was. +async function removeNote(note: Note) { + const id = note.id; + await changeNotes((notes) => notes.filter((n) => n.id !== id)); + toast( + !isHighlight(note) + ? 'Nota excluída.' + : note.text.trim() + ? 'Realce e nota excluídos.' + : 'Realce excluído.', + { + label: 'Desfazer', + run: () => + void changeNotes((notes) => + notes.some((n) => n.id === id) ? notes : [...notes, note], + ).catch(failing(FAILED.undo)), + }, + ); +} +// The options of a note or a highlight: from its mark in the page, from the text itself or from +// its card in the side bar. +function openNoteMenu( + anchor: HTMLElement, + note: Note, + options: Pick = {}, +) { + noteMenu( + anchor, + note, + { + edit: (n) => editNote(n), + color: (n, color) => + void changeNotes((notes) => notes.map((x) => (x.id === n.id ? { ...x, color } : x))).catch( + failing('Não foi possível mudar a cor do realce.'), + ), + copy: (n) => void copyNote(n), + remove: (n) => void removeNote(n).catch(failing(FAILED.removeNote)), + }, + options, + ); +} +// The dialog of a note. A new note quotes the passage that is selected in the pages, if there is +// one (`picked` is what was selected when the dialog was asked for), and the lines of that passage +// make it a highlight. A selection across pages gives the part on the first page. An old note +// without lines stays a note: no colour, no mark in the page. A highlight with no text is valid. +function editNote(existing?: Note, picked?: SelectionCapture | null) { if (!current) return; - const selection = window.getSelection(); - const quote = - existing?.quote || - (selection?.anchorNode && $('#pdf-viewer').contains(selection.anchorNode) - ? selection.toString().trim().slice(0, 20000) - : ''); - const page = existing?.page || current.page; - const id = current.id; + const capture = existing ? null : (picked ?? reader?.captureSelection() ?? null); + const piece = capture?.pieces[0]; + const quote = existing ? existing.quote : (piece?.quote ?? ''); + const page = existing?.page ?? piece?.page ?? current.page; + const rects = existing ? noteRects(existing) : (piece?.rects ?? []); + const color: NoteColor = existing ? colorOf(existing) : rememberedColor(); + const colors = `
    Cor do realce
    ${NOTE_COLORS.map( + (c) => + ``, + ).join('')}
    `; + // Where the dialog was asked for, when that was in the Notas tab (the button of a card, or the one + // that adds a note): saving or deleting draws the tab again, and the dialog cannot give the focus + // back to a button that is no longer there. + const origin = notesTabFocus(); const d = modal( - `

    ${existing ? 'Editar nota' : 'Nova nota'} Página ${page}

    ${ib('close-note', 'Fechar', 'close', 'data-close')}
    ${quote ? `
    ${esc(quote)}
    ` : '

    Registre uma ideia ou selecione um trecho no PDF para citá-lo.

    '}
    ${existing ? '' : ''}
    `, + `

    ${existing ? 'Editar nota' : 'Adicionar nota'} Página ${page}

    ${ib('close-note', 'Fechar', 'close', 'data-close')}
    ${quote ? `
    ${esc(quote)}
    ` : '

    Registre uma ideia ou selecione um trecho no PDF para citá-lo.

    '}${(capture?.pieces.length ?? 0) > 1 ? `

    A seleção passa por mais de uma página. A nota guarda só o trecho da página ${page}.

    ` : ''}${rects.length ? colors : ''}
    ${existing ? `` : ''}
    `, + ); + // Esc must not throw away a note that was typed and not saved, without asking. The question is + // in the dialog itself; a second Esc, or "Descartar", closes it. The X and "Cancelar" are chosen + // on purpose, so they close at once. + const field = $('#note-text'), + initial = field.value; + const ask = (asking: boolean) => { + $('#note-actions').hidden = asking; + $('#note-discard').hidden = !asking; + (asking ? $('#note-keep') : field).focus(); + }; + const onCancel = (e: Event) => { + if (field.value === initial || !$('#note-discard').hidden || !e.cancelable) return; + e.preventDefault(); + ask(true); + }; + d.addEventListener('cancel', onCancel); + d.addEventListener( + 'close', + () => { + d.removeEventListener('cancel', onCancel); + // The dialog gave the focus back, unless its button was gone: then nothing has it. The + // note that was saved or edited has its card; one that was deleted, the one next to it. + const lost = !document.activeElement || document.activeElement === document.body; + if (lost) backToNotesTab(origin); + }, + { once: true }, ); + $('#note-keep').onclick = () => ask(false); + $('#note-discard-yes').onclick = () => d.close(); $('#save-note').onclick = async () => { const text = $('#note-text').value.trim(); - if (!text && !quote) { + if (!text && !quote && !rects.length) { toast('Escreva uma nota antes de salvar.'); return; } + const chosen = d.querySelector('input[name="note-color"]:checked')?.value; + const note: Note = { + id: existing?.id || crypto.randomUUID(), + page, + text, + quote, + created: existing?.created || Date.now(), + ...(rects.length ? { rects, color: NOTE_COLORS.find((c) => c === chosen) ?? color } : {}), + }; try { - await flushReading(); - const notes = [...(current?.notes || [])]; - const note = { - id: existing?.id || crypto.randomUUID(), - page, - text, - quote, - created: existing?.created || Date.now(), - }; - const idx = notes.findIndex((n) => n.id === note.id); - if (idx >= 0) notes[idx] = note; - else notes.push(note); - await patchBook(id, { notes }); + await changeNotes((notes) => { + const idx = notes.findIndex((n) => n.id === note.id); + if (idx >= 0) notes[idx] = note; + else notes.push(note); + return notes; + }); d.close(); - toggleSidebar('notes'); - toast('Nota salva.'); + if (note.color) rememberColor(note.color); + // A highlight shows itself in the page; a note without a place is shown in the side bar. + if (!rects.length) toggleSidebar('notes'); + // What the screen already shows is not said again in a box: the highlight that a new note makes, + // or the card of the note in the Notas tab. A screen reader hears it in the quiet region. Only + // the text of a highlight that was edited with that tab out of view has nothing to show for it. + const shown = !existing || (!$('#reader-sidebar').hidden && prefs.sidebarTab === 'notes'); + if (shown) announce('Nota salva.'); + else toast('Nota salva.'); } catch (e) { - fail(e); + fail(e, 'Não foi possível salvar a nota.'); } }; $('#delete-note')?.addEventListener('click', async () => { try { - await patchBook(id, { notes: current!.notes.filter((n) => n.id !== existing!.id) }); + await removeNote(existing!); d.close(); - renderSidebar(); } catch (e) { - fail(e); + fail(e, FAILED.removeNote); } }); } $('#import-main').onclick = () => void importFiles(); +$('#import-folder').onclick = () => void importFolder(); $('#library-search').oninput = () => { query = $('#library-search').value; + const ticked = selected.size; renderLibrary(); + // The drawing takes out of the selection what the search hides: say so once, in the same words + // for every key that takes one more out, so that the message is renewed and does not pile up. + if (selected.size < ticked) toast('Os documentos que a busca esconde saem da seleção.'); }; -document.querySelectorAll('[data-filter]').forEach( - (b) => - (b.onclick = () => { - filter = b.dataset.filter!; - renderLibrary(); - }), -); +document + .querySelectorAll('[data-filter]') + .forEach((b) => (b.onclick = () => chooseFilter(b.dataset.filter!))); +// The tags are drawn by the library, so one listener on their list serves all of them. +$('#tag-nav').onclick = (e) => { + const button = (e.target as HTMLElement).closest('[data-filter]'); + if (button) chooseFilter(button.dataset.filter!); +}; +$('#select-favorite').onclick = () => void favoriteSelected(); +$('#select-finish').onclick = () => void finishSelected(); +$('#select-tag').onclick = () => editTags(chosenBooks()); +$('#select-remove').onclick = () => void removeSelected(); +$('#select-restore').onclick = () => void restoreBooks(chosenBooks().map((b) => b.id)); +$('#select-purge').onclick = () => void purgeBooks(chosenBooks().map((b) => b.id)); +$('#select-clear').onclick = () => void clearSelection(); +$('#empty-trash').onclick = () => + void purgeBooks( + snapshot.trash.map((b) => b.id), + true, + ); $('#sort').onchange = () => { prefs.sort = $('#sort').value; renderLibrary(); @@ -565,54 +1837,121 @@ $('#view-list').onclick = () => { prefs.view = 'list'; renderLibrary(); }; -$('#settings').onclick = preferences; -$('#reader-settings').onclick = preferences; +// The arrows choose between the two, as in any radio group, and take the focus along. +const viewKeys: Record = { + ArrowLeft: 'grid', + ArrowUp: 'grid', + ArrowRight: 'list', + ArrowDown: 'list', +}; +$('.segmented').onkeydown = (e) => { + const view = viewKeys[e.key]; + if (!view || e.altKey || e.ctrlKey || e.metaKey) return; + e.preventDefault(); + prefs.view = view; + renderLibrary(); + $(`#view-${view}`).focus(); +}; +$('#settings').onclick = () => preferences(); $('#help').onclick = showHelp; $('#theme-toggle').onclick = () => { prefs.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; applyPrefs(); }; -$('#folder-link').onclick = () => void invoke('open_folder').catch(fail); -$('#back').onclick = () => void closeReader().catch(fail); -$('#cancel-loading').onclick = () => void closeReader().catch(fail); -$('#restart').onclick = restartReading; +$('#back').onclick = () => void closeReader().catch(failing(FAILED.back)); +$('#cancel-loading').onclick = () => void closeReader().catch(failing(FAILED.back)); $('#reader-favorite').onclick = () => current && void toggleFavorite(current.id); $('#reader-more').onclick = () => current && bookMenu($('#reader-more'), current); +$('#finish-reading').onclick = () => + current && void finishBook(current.id).catch(failing(FAILED.finish)); +$('#goals').onclick = () => void goals().catch(failing(FAILED.goals)); +$('#reading-history').onclick = () => void history().catch(failing(FAILED.history)); +$('#library-goal').onclick = () => void goals().catch(failing(FAILED.goals)); +$('#reader-goal').onclick = () => current && void goals(current.id).catch(failing(FAILED.goals)); $('#kindle-toggle').onclick = () => { setReadingMode(prefs.readingMode === 'kindle' ? 'scroll' : 'kindle'); $('#viewer-container').focus(); + // Only the first time: after that, the reader knows the keys. if (prefs.readingMode === 'kindle') - toast('Modo Kindle: use ← →, Espaço ou a roda do mouse para passar as telas.'); + showHint('kindle', 'Modo Kindle: use ← →, Espaço ou a roda do mouse para passar as telas.'); +}; +$('#spread-toggle').onclick = () => { + prefs.spread = prefs.spread === 'none' ? pairLayout : 'none'; + applyPrefs(); }; $('#previous').onclick = () => reader?.turn(-1); $('#next').onclick = () => reader?.turn(1); $('#page-number').onchange = () => { - reader?.goTo(Number($('#page-number').value)); + reader?.jumpTo(Number($('#page-number').value)); updateReaderMeta(); }; $('#page-number').onblur = updateReaderMeta; $('#zoom').onchange = () => reader?.zoom($('#zoom').value); $('#zoom-in').onclick = () => reader?.stepZoom(1); $('#zoom-out').onclick = () => reader?.stepZoom(-1); -$('#rotate').onclick = () => { - reader?.rotate(); - if (current && reader) queueReading({ rotation: reader.rotation }); -}; -$('#bookmark').onclick = () => void toggleBookmark().catch(fail); -$('#paper-toggle').onclick = () => { - prefs.paper = prefs.paper === 'dark' ? 'light' : 'dark'; +// The rotation is saved with the book, so a turn that was not meant has a way back: the message +// says it, and "Desfazer" turns the pages back by the same quarter. One message at a time, for the +// last turn, so that a few presses in a row do not pile them up. +let closeRotationNotice: (() => void) | null = null; +function rotatePages(degrees = 90) { + if (!reader) return false; + const before = reader.rotation; + reader.rotate(degrees); + if (reader.rotation === before) return false; + thumbnails?.refresh(); + if (current) queueReading({ rotation: reader.rotation }); + return true; +} +// From the menu of the document and from the R key. +function rotateWithUndo() { + if (!rotatePages()) return; + closeRotationNotice?.(); + closeRotationNotice = toast('Páginas giradas.', { + label: 'Desfazer', + run: () => void rotatePages(270), + }); +} +$('#bookmark').onclick = () => void toggleBookmark().catch(failing(FAILED.bookmark)); +// The D key: the paper goes on to the next, and the panel of Aparência (open or not) follows. +function cyclePaper() { + prefs.paper = nextPaper(prefs.paper); applyPrefs(); -}; -$('#focus').onclick = toggleFocus; -$('#exit-focus').onclick = toggleFocus; -$('#fullscreen').onclick = async () => { - const w = getCurrentWindow(); - await w.setFullscreen(!(await w.isFullscreen())); -}; + announce(`Papel ${PAPER_NAMES[prefs.paper]}.`); +} +$('#fullscreen').onclick = () => + void chrome.toggle().then(() => { + // Only the first time: the bars are away, and nothing on the screen says how to bring them back. + if (document.body.classList.contains('reader-fullscreen')) + showHint( + 'fullscreen', + 'Tela cheia: leve o mouse ao topo ou à parte inferior para mostrar os controles. Esc sai.', + ); + }); $('#sidebar-toggle').onclick = () => toggleSidebar(); document .querySelectorAll('[data-tab]') - .forEach((b) => (b.onclick = () => toggleSidebar(b.dataset.tab))); + .forEach((b) => (b.onclick = () => toggleSidebar(b.dataset.tab as SidebarTab))); +$('.side-tabs').addEventListener('keydown', (e) => { + const next = tabAfterKey(prefs.sidebarTab, e.key); + if (!next || e.altKey || e.ctrlKey || e.metaKey) return; + e.preventDefault(); + e.stopPropagation(); + toggleSidebar(next); + $(`#tab-${next}`).focus(); +}); +bindSidebarResizer($('#sidebar-resizer'), { + width: () => Math.round($('#reader-sidebar').getBoundingClientRect().width), + preview: (width) => { + $('#reader-sidebar').style.setProperty('--sidebar-width', `${width}px`); + syncResizer(); + }, + commit: (width) => { + prefs.sidebarWidth = width; + applyPrefs(); + reader?.resize(); + }, +}); +addEventListener('resize', syncResizer); $('#add-note').onmousedown = (e) => e.preventDefault(); $('#add-note').onclick = () => editNote(); $('#find-toggle').onclick = showFind; @@ -630,6 +1969,7 @@ $('#find-close').onclick = () => { $('#find-bar').hidden = true; reader?.closeFind(); $('#viewer-container').focus(); + chrome.releaseTop(); reader?.resize(); }; @@ -639,14 +1979,38 @@ bindShortcuts({ importFiles, closeReader, toggleBookmark, - toggleFocus, + exitFullscreen: () => chrome.exit(), showFind, + jumpBack: () => stepJump('back'), + jumpForward: () => stepJump('forward'), + selectAll, + clearSelection, + highlightSelection: () => highlightSelection(), + annotateSelection, + dismissSelection: () => selectionBar.dismiss(), + singleKeys: () => prefs.singleKeys, + showHelp, + cyclePaper, + rotate: rotateWithUndo, + clearSearch: () => { + if (!$('#library-search').value) return false; + clearSearch(); + return true; + }, }); async function init() { - await refresh(); - await listen<{ done: number; total: number }>('import-progress', (e) => - toast(`Copiando PDFs… ${e.payload.done} de ${e.payload.total}`), + await getCurrentWindow().onResized( + () => void chrome.sync().catch(failing('Não foi possível atualizar a tela cheia.')), ); + await chrome.sync(); + await refresh(); + // Books that came without a cover or a page count (an import from before, or one that was cut + // short) are completed in the background. + enrichment.add(waitingForMetadata(snapshot.books)); + // The progress of the copy goes to the import button, and not to a message for each file. + await listen<{ done: number; total: number }>('import-progress', (e) => { + if (busy) showImportProgress(e.payload.done, e.payload.total); + }); await getCurrentWindow().onDragDropEvent((e) => { const data = e.payload; $('#drop-overlay').hidden = data.type !== 'over' && data.type !== 'enter'; @@ -661,16 +2025,38 @@ async function init() { if (current) { e.preventDefault(); try { + // The window is remembered by its size and position: not those of the fullscreen. + await chrome.exit().catch(() => {}); await flushReading(); await getCurrentWindow().destroy(); - } catch { - toast('Não foi possível salvar a posição. Tente novamente.'); + } catch (error) { + // The window stays open and the position is not lost. A failed save has told why (see + // `flushReading`); anything else is said here. + fail(error, 'Não foi possível fechar o Fólio.'); } } }); + // The PDFs the app was opened with wait in the backend; a second launch says there are more. + await listen('open-files', () => void takeLaunched().catch(failing(FAILED.openFiles))); + void takeLaunched().catch(failing(FAILED.openFiles)); } -void init().catch((e) => { - modal( - `

    Não foi possível abrir a biblioteca

    ${esc(e)}

    Seus PDFs não foram alterados.

    `, +// The way out of a library that did not open: the page loads again. It is a listener, because the +// policy of the page (CSP) does not run a handler written in the HTML. Esc, or any other way of +// closing the dialog, tries again as well, so that the window is never left empty with no way out. +function showStartError(error: unknown) { + const { message, detail } = describeError(error); + const d = modal( + `

    Não foi possível abrir a biblioteca

    ${esc(message)}

    ${detail ? `
    Detalhes

    ${esc(detail)}

    ` : ''}

    Seus PDFs não foram alterados.

    `, ); -}); + const retry = () => location.reload(); + $('#retry-start').addEventListener('click', retry); + d.addEventListener( + 'close', + () => { + // Another dialog may have taken its place by now: that one is not this one's to close. + if (document.getElementById('retry-start')) retry(); + }, + { once: true }, + ); +} +void init().catch(showStartError); diff --git a/src/pdf-source.ts b/src/pdf-source.ts new file mode 100644 index 0000000..4a18273 --- /dev/null +++ b/src/pdf-source.ts @@ -0,0 +1,120 @@ +import * as pdfjs from 'pdfjs-dist'; +import type { + PDFDocumentLoadingTask, + PDFDocumentProxy, + PDFPageProxy, + PageViewport, +} from 'pdfjs-dist'; +import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + +// How the reader and the library queue open a document: the same transport, from the copy kept +// in the library. +export type PdfSourceOptions = { + // Stops the reading of the file. Once it is aborted the document is not opened. + signal: AbortSignal; + // Called as soon as the loading task exists, so that the caller can destroy it at any time. + onTask?: (task: PDFDocumentLoadingTask) => void; + // Asks for the password of a protected document: `null` gives up. Without it a protected + // document fails to open, which is what a background job wants. + onPassword?: (incorrect: boolean) => Promise; +}; +export type PdfSource = { + task: PDFDocumentLoadingTask; + // Resolves with the document, or rejects if it is not a PDF, needs a password that was not + // given, or a part of the file could not be read. + document: Promise; +}; + +// Tauri's asset protocol handles Range requests but doesn't advertise them on the initial +// response. Supply an explicit transport to avoid reading entire large documents into native +// memory before PDF.js can discover range support. Resolves with `null` when `signal` was +// aborted while the first bytes were being read. +export async function openPdfSource( + url: string, + size: number, + { signal, onTask, onPassword }: PdfSourceOptions, +): Promise { + const readRange = async (begin: number, end: number) => { + const bytes = new Uint8Array(end - begin); + for (let start = begin; start < end; start += 524288) { + const stop = Math.min(end, start + 524288); + const response = await fetch(url, { + headers: { Range: `bytes=${start}-${stop - 1}` }, + signal, + }); + if (response.status !== 206) + throw new Error(`Não foi possível ler parte do PDF (${response.status}).`); + const part = new Uint8Array(await response.arrayBuffer()); + if (part.length !== stop - start) + throw new Error('A cópia do PDF foi alterada ou está incompleta.'); + bytes.set(part, start - begin); + } + return bytes; + }; + const initial = await readRange(0, Math.min(size, 262144)); + if (signal.aborted) return null; + let rejectRange!: (error: unknown) => void; + const rangeFailure = new Promise((_, reject) => (rejectRange = reject)); + const transport = new pdfjs.PDFDataRangeTransport(size, initial, false); + transport.requestDataRange = (begin: number, end: number) => { + void readRange(begin, end) + .then((bytes) => { + if (!signal.aborted) transport.onDataRange(begin, bytes); + }) + .catch((error) => { + if (!signal.aborted) { + rejectRange(error); + void task.destroy(); + } + }); + }; + transport.abort = () => {}; + const task = pdfjs.getDocument({ + range: transport, + cMapUrl: new URL('/pdfjs/cmaps/', location.href).href, + cMapPacked: true, + standardFontDataUrl: new URL('/pdfjs/standard_fonts/', location.href).href, + wasmUrl: new URL('/pdfjs/wasm/', location.href).href, + enableXfa: false, + disableAutoFetch: true, + disableStream: true, + rangeChunkSize: 262144, + }); + onTask?.(task); + if (onPassword) + task.onPassword = async (updatePassword: (value: string) => void, reason: number) => { + const password = await onPassword(reason === pdfjs.PasswordResponses.INCORRECT_PASSWORD); + if (password === null) { + await task.destroy(); + return; + } + updatePassword(password); + }; + return { task, document: Promise.race([task.promise, rangeFailure]) }; +} + +// The width of a cover, in pixels. Cards show it small, so this is plenty. +export const COVER_WIDTH = 240; + +// Draws the first page of a document as a PNG cover. `viewportAt` gives the page's viewport at a +// scale: the reader passes its own, which knows the page's whole area; by default it is the page's. +// Resolves with `null` if the browser could not encode the image. +export async function renderCover( + page: PDFPageProxy, + viewportAt: (scale: number) => PageViewport = (scale) => page.getViewport({ scale }), +): Promise { + const viewport = viewportAt(1); + const scaled = viewportAt(COVER_WIDTH / viewport.width); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(scaled.width); + canvas.height = Math.ceil(scaled.height); + try { + await page.render({ canvas, viewport: scaled }).promise; + const blob = await new Promise((r) => canvas.toBlob(r, 'image/png')); + return blob ? new Uint8Array(await blob.arrayBuffer()) : null; + } finally { + canvas.width = 0; + canvas.height = 0; + } +} diff --git a/src/preferences.ts b/src/preferences.ts index a185d8b..9864703 100644 --- a/src/preferences.ts +++ b/src/preferences.ts @@ -1,18 +1,35 @@ import type { Preferences } from './types'; +import { normalizeGoal } from './reading/goals.ts'; +import { normalizeGoalHistory } from './reading/history.ts'; +import { normalizeHints } from './hints.ts'; +import { normalizePace } from './reading/pace.ts'; +import { SIDEBAR_TABS, SIDEBAR_WIDTH } from './reading/sidebar.ts'; +import { SPREADS } from './reading/spread.ts'; // This key is part of the existing installation's data contract. Branding can // change without resetting the reader's appearance or resume preference. export const PREFERENCES_KEY = 'lume.preferences'; export const DEFAULT_PREFERENCES: Readonly = { + pace: {}, + goalHistory: {}, + dailyGoal: null, theme: 'light', paper: 'light', warm: 0, brightness: 100, readingMode: 'scroll', + spread: 'none', + sidebarOpen: false, + sidebarTab: 'outline', + sidebarWidth: SIDEBAR_WIDTH.fallback, + joinPages: true, + chapterBreaks: true, resume: true, + singleKeys: true, view: 'grid', sort: 'recent', + hints: [], }; export function normalizePreferences(value: unknown): Preferences { @@ -24,14 +41,30 @@ export function normalizePreferences(value: unknown): Preferences { const choice = (value: unknown, options: readonly T[], fallback: T): T => options.includes(value as T) ? (value as T) : fallback; return { + pace: normalizePace(saved.pace), + goalHistory: normalizeGoalHistory(saved.goalHistory), + dailyGoal: normalizeGoal(saved.dailyGoal), theme: choice(saved.theme, ['light', 'dark', 'system'], 'light'), paper: choice(saved.paper, ['light', 'dark', 'sepia'], 'light'), warm: number(saved.warm, 0, 0, 70), brightness: number(saved.brightness, 100, 10, 100), readingMode: choice(saved.readingMode, ['scroll', 'kindle'], 'scroll'), + spread: choice(saved.spread, SPREADS, 'none'), + // The side bar of the reader: shown or not, its tab and its width, for every book. + sidebarOpen: typeof saved.sidebarOpen === 'boolean' ? saved.sidebarOpen : false, + sidebarTab: choice(saved.sidebarTab, SIDEBAR_TABS, 'outline'), + sidebarWidth: Math.round( + number(saved.sidebarWidth, SIDEBAR_WIDTH.fallback, SIDEBAR_WIDTH.min, SIDEBAR_WIDTH.max), + ), + joinPages: typeof saved.joinPages === 'boolean' ? saved.joinPages : true, + chapterBreaks: typeof saved.chapterBreaks === 'boolean' ? saved.chapterBreaks : true, resume: typeof saved.resume === 'boolean' ? saved.resume : true, + // On unless it was turned off, so that nothing changes for those who never open the option. + singleKeys: typeof saved.singleKeys === 'boolean' ? saved.singleKeys : true, view: choice(saved.view, ['grid', 'list'], 'grid'), sort: choice(saved.sort, ['recent', 'title', 'size', 'added'], 'recent'), + // The tips that were shown and must not come back: known names only, each once. + hints: normalizeHints(saved.hints), }; } @@ -39,7 +72,7 @@ export function loadPreferences(storage: Pick): Preferences try { return normalizePreferences(JSON.parse(storage.getItem(PREFERENCES_KEY) || '{}')); } catch { - return { ...DEFAULT_PREFERENCES }; + return { ...DEFAULT_PREFERENCES, hints: [] }; } } diff --git a/src/reader.ts b/src/reader.ts index 182ec0c..3d0a42a 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -1,27 +1,61 @@ import * as pdfjs from 'pdfjs-dist'; import { EventBus, + FindState, PDFViewer, PDFLinkService, PDFFindController, } from 'pdfjs-dist/web/pdf_viewer.mjs'; import 'pdfjs-dist/web/pdf_viewer.css'; -import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; -import type { Book, ReadingMode } from './types'; +import type { Book, Note, ReadingMode, Spread } from './types'; +import { openPdfSource, renderCover } from './pdf-source'; import { turnScreen, type EdgeReturn } from './reading/screens'; -pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; +import { JoinedPages } from './reading/joined-pages'; +import { ChapterBreaks } from './reading/chapter-breaks'; +import { PDFJS_SPREAD, screenPages, sharesScreen } from './reading/spread'; +import { resolveOutline, type OutlineEntry } from './reading/outline'; +import { JumpHistory, type JumpOffer, type Position } from './reading/jumps'; +import { NoteLayers, viewportRect, type NoteOpen } from './reading/note-layer'; +import { noteRects } from './reading/note-rects'; +import { + captureSelection as readSelection, + type SelectionCapture, +} from './reading/selection-capture'; +import { + extendSelection, + isSelectionKey, + selectionEnd, + visibleArea, + type Extension, +} from './reading/selection-keys'; type Options = { container: HTMLElement; viewer: HTMLElement; mode: ReadingMode; + spread: Spread; + joinPages: boolean; + chapterBreaks: boolean; onViewport: () => void; + onOutline: () => void; onPage: (page: number) => void; onLocation: (patch: Partial) => void; onZoom: (value: string) => void; onFind: (text: string) => void; + // A jump was kept, or undone: the way back, or forward, that the reader can offer now. + onJump: (offer: JumpOffer) => void; onPassword: (incorrect: boolean) => Promise; + // A highlight, or the mark of a note, was clicked (see reading/note-layer.ts). + onNote: NoteOpen; + // The keyboard grew the selection of the pages (see `extendSelection`). + onSelect: () => void; }; export class Reader { + get chapterStarts() { + return this.chapters?.starts || []; + } + setGoalTracking(active: boolean) { + this.chapters?.track(active); + } private bus = new EventBus(); private link: PDFLinkService; private findController: PDFFindController; @@ -34,13 +68,30 @@ export class Reader { private dead = false; private options: Options; private zoomTimer: ReturnType | undefined; - outline: any[] = []; + // The outline, once the document has given it. A book without one is also "ready". + outline: OutlineEntry[] = []; + outlineReady = false; + // Page shapes at scale 1 (width, height, with the page's own rotation only), from the + // pages that have been loaded; the placeholders of the thumbnails use them. + private sizes = new Map(); + private thumbnailTasks = new Set(); private location: { pageNumber: number; top: number; left: number } | null = null; private edgeReturn: EdgeReturn = null; private wheelTime = 0; private wheelAmount = 0; private wheelTurned = false; private touchStart: { x: number; y: number } | null = null; + private joined: JoinedPages | null = null; + private chapters: ChapterBreaks | null = null; + private changingLayout = false; + // The way back after a jump, for as long as this document is open. + private jumps = new JumpHistory(); + // Where the reader was when the search began, until its first result is kept as a jump; and + // whether it was, so that the next results of the same search are not jumps. + private findFrom: Position | null = null; + private finding = false; + // The highlights, one layer for each page (see reading/note-layer.ts). + private noteLayers: NoteLayers; constructor(options: Options) { this.options = options; this.link = new PDFLinkService({ @@ -49,6 +100,7 @@ export class Reader { externalLinkRel: 'noopener noreferrer', ignoreDestinationZoom: true, }); + this.keepWayBack(); this.findController = new PDFFindController({ eventBus: this.bus, linkService: this.link }); this.viewer = new PDFViewer({ container: options.container as HTMLDivElement, @@ -64,8 +116,14 @@ export class Reader { removePageBorders: false, }); this.link.setViewer(this.viewer); + this.noteLayers = new NoteLayers( + this.viewer as any, + options.container, + options.onNote, + this.abort.signal, + ); this.bus.on('pagechanging', (e: any) => { - if (this.ready) options.onPage(e.pageNumber); + if (this.ready && !this.changingLayout) options.onPage(e.pageNumber); }); this.bus.on('scalechanging', () => { if (this.ready) { @@ -74,7 +132,7 @@ export class Reader { } }); this.bus.on('updateviewarea', (e: any) => { - if (this.ready && !this.dead) { + if (this.ready && !this.dead && !this.changingLayout) { const l = e.location; this.location = l; options.onLocation({ @@ -85,8 +143,21 @@ export class Reader { rotation: this.viewer.pagesRotation, }); options.onViewport(); + this.chapters?.update(); } }); + this.bus.on('pagerendered', (e: any) => { + if (!e.error && !this.dead) this.joined?.rendered(e.pageNumber); + if (!e.error && !this.dead) this.chapters?.rendered(e.pageNumber); + // PDF.js clears the page's other layers whenever it draws it again; the highlights come back. + if (!e.error && !this.dead) this.noteLayers.draw(e.pageNumber); + }); + // A new zoom or rotation resets every page, before it tells; the layers come back with the + // new size, and the pages that were not drawn yet get theirs as they are. + for (const event of ['scalechanging', 'rotationchanging', 'spreadmodechanged']) + this.bus.on(event, () => { + if (!this.dead) this.noteLayers.drawAll(); + }); this.bus.on('updatefindmatchescount', (e: any) => options.onFind( e.matchesCount.total @@ -101,6 +172,7 @@ export class Reader { options.onFind( e.matchesCount.total ? `${e.matchesCount.current} de ${e.matchesCount.total}` : '', ); + this.found(e.state); }); this.observer = new ResizeObserver(() => this.resize()); this.observer.observe(options.container); @@ -141,77 +213,53 @@ export class Reader { ); } async load(url: string, book: Book) { - // Tauri's asset protocol handles Range requests but doesn't advertise them on - // the initial response. Supply an explicit transport to avoid reading entire - // large documents into native memory before PDF.js can discover range support. - const readRange = async (begin: number, end: number) => { - const bytes = new Uint8Array(end - begin); - for (let start = begin; start < end; start += 524288) { - const stop = Math.min(end, start + 524288); - const response = await fetch(url, { - headers: { Range: `bytes=${start}-${stop - 1}` }, - signal: this.abort.signal, - }); - if (response.status !== 206) - throw new Error(`Não foi possível ler parte do PDF (${response.status}).`); - const part = new Uint8Array(await response.arrayBuffer()); - if (part.length !== stop - start) - throw new Error('A cópia do PDF foi alterada ou está incompleta.'); - bytes.set(part, start - begin); - } - return bytes; - }; - const initial = await readRange(0, Math.min(book.size, 262144)); - if (this.dead) return; - let rejectRange!: (error: unknown) => void; - const rangeFailure = new Promise((_, reject) => (rejectRange = reject)); - const transport = new pdfjs.PDFDataRangeTransport(book.size, initial, false); - transport.requestDataRange = (begin: number, end: number) => { - void readRange(begin, end) - .then((bytes) => { - if (!this.dead) transport.onDataRange(begin, bytes); - }) - .catch((error) => { - if (!this.dead) { - rejectRange(error); - void this.task?.destroy(); - } - }); - }; - transport.abort = () => {}; - this.task = pdfjs.getDocument({ - range: transport, - cMapUrl: new URL('/pdfjs/cmaps/', location.href).href, - cMapPacked: true, - standardFontDataUrl: new URL('/pdfjs/standard_fonts/', location.href).href, - wasmUrl: new URL('/pdfjs/wasm/', location.href).href, - enableXfa: false, - disableAutoFetch: true, - disableStream: true, - rangeChunkSize: 262144, + // The same transport as the library queue, over the copy kept in the library. + const source = await openPdfSource(url, book.size, { + signal: this.abort.signal, + onTask: (task) => (this.task = task), + onPassword: this.options.onPassword, }); - this.task.onPassword = async (updatePassword: (value: string) => void, reason: number) => { - const p = await this.options.onPassword( - reason === pdfjs.PasswordResponses.INCORRECT_PASSWORD, - ); - if (p === null) { - await this.task?.destroy(); - return; - } - updatePassword(p); - }; - this.pdf = await Promise.race([this.task.promise, rangeFailure]); + if (!source) return; + this.noteLayers.set(book.notes ?? []); + this.pdf = await source.document; if (this.dead) return; + this.joined = new JoinedPages( + this.pdf, + book.hash, + this.viewer, + () => this.kindle && this.options.joinPages, + () => this.relayout(), + ); + // The first page decides the shape of every page that has not been loaded yet. + const first = await this.pdf.getPage(1).catch(() => null); + if (this.dead) return; + if (first) { + const view = this.joined.fullViewport(first, 1); + this.sizes.set(1, [view.width, view.height]); + } + this.chapters = new ChapterBreaks( + this.pdf, + this.viewer, + this.options.container, + () => this.kindle && this.options.chapterBreaks, + () => { + this.chapters?.update(); + this.options.onViewport(); + }, + ); const initialized = new Promise((resolve) => { this.bus.on('pagesinit', () => { + this.joined?.apply(); + // Before the zoom, so that "page width" already counts both pages of a pair. + this.applySpread(); this.viewer.currentScaleValue = book.zoom || 'page-width'; this.viewer.pagesRotation = book.rotation || 0; this.viewer.currentPageNumber = Math.min(this.pages, Math.max(1, book.page || 1)); if (book.top !== null && book.top !== undefined) - this.viewer.scrollPageIntoView({ + this.restoreAnchor({ pageNumber: this.viewer.currentPageNumber, - destArray: [null, { name: 'XYZ' }, book.left || 0, book.top, null], - allowNegativeOffset: true, + left: book.left || 0, + top: book.top, }); this.ready = true; this.options.onPage(this.viewer.currentPageNumber); @@ -223,10 +271,17 @@ export class Reader { this.viewer.setDocument(this.pdf); await initialized; // Do not delay the first page while loading document navigation. - void this.pdf + const pdf = this.pdf; + void pdf .getOutline() - .then((items) => { - if (!this.dead) this.outline = items || []; + .catch(() => null) + .then((items) => resolveOutline(pdf, items)) + .then((entries) => { + if (this.dead) return; + this.outline = entries; + this.outlineReady = true; + this.chapters?.outline(entries); + this.options.onOutline(); }) .catch(() => {}); } @@ -242,38 +297,124 @@ export class Reader { get kindle() { return this.options.mode === 'kindle'; } + // The layout in effect. Kindle needs one column, so it hides the chosen spread + // without forgetting it: the preference stays in `options` and comes back. + get spread(): Spread { + return this.kindle ? 'none' : this.options.spread; + } + // The pages on screen together (one, or the pair of a two-page layout). `first` + // is stable for a given pair, whichever page PDF.js reports as current. + get screenPages() { + return this.screenOf(this.page); + } + // The same for another page: the side bar marks the page that the footer shows, which + // comes from the position, and PDF.js can call another page current. + screenOf(page: number) { + const box = this.options.container.getBoundingClientRect(); + return screenPages(page, this.pages, this.spread, (n) => { + const view = this.viewer.getPageView(n - 1)?.div; + return !!view && sharesScreen(view.getBoundingClientRect(), box); + }); + } get screen() { const c = this.options.container, max = Math.max(0, c.scrollHeight - c.clientHeight); return { atStart: c.scrollTop <= 1, atEnd: c.scrollTop >= max - 1, - progress: max ? Math.round((c.scrollTop / max) * 100) : 100, + // A document that fits in the area has no end to reach: it is all there from the start. + scrolls: max > 1, }; } - setMode(mode: ReadingMode) { - if (mode === this.options.mode) return; + setMode( + mode: ReadingMode, + joinPages = this.options.joinPages, + chapterBreaks = this.options.chapterBreaks, + spread = this.options.spread, + ) { + if ( + mode === this.options.mode && + joinPages === this.options.joinPages && + chapterBreaks === this.options.chapterBreaks && + spread === this.options.spread + ) + return; // Keep a PDF-coordinate anchor while the gaps between pages change. if (this.ready) this.viewer.update(); const anchor = this.location; this.options.mode = mode; + this.options.joinPages = joinPages; + this.options.chapterBreaks = chapterBreaks; + this.options.spread = spread; this.options.container.dataset.readingMode = mode; this.edgeReturn = null; this.wheelTime = 0; if (this.ready) { - if (anchor) - this.viewer.scrollPageIntoView({ - pageNumber: anchor.pageNumber, - destArray: [null, { name: 'XYZ' }, anchor.left, anchor.top, null], - allowNegativeOffset: true, - }); + this.changingLayout = true; + try { + // The page boxes first: PDF.js sizes "page width" for the pair from them. + this.joined?.apply(); + this.applySpread(); + this.restoreAnchor(anchor); + } finally { + this.changingLayout = false; + } this.viewer.update(); + this.chapters?.refresh(); + this.noteLayers.drawAll(); + } + } + // PDF.js regroups the pages and recalculates the "page width" and "whole page" + // zooms for the new layout; the caller restores the reading position. + private applySpread() { + const container = this.options.container; + if (this.spread === 'none') delete container.dataset.spread; + else container.dataset.spread = this.spread; + const mode = PDFJS_SPREAD[this.spread]; + if (this.viewer.spreadMode !== mode) this.viewer.spreadMode = mode; + } + private restoreAnchor(anchor: typeof this.location) { + if (!anchor) return; + const viewport = this.viewer.getPageView(anchor.pageNumber - 1).viewport; + const point = viewport.convertToViewportPoint(anchor.left, anchor.top); + // A previously saved point in a removed margin lands on the nearest kept + // part of the same page, rather than silently jumping to an adjacent page. + const [left, top] = viewport.convertToPdfPoint( + point[0], + Math.max(0, Math.min(viewport.height - 1, point[1])), + ); + this.viewer.scrollPageIntoView({ + pageNumber: anchor.pageNumber, + destArray: [null, { name: 'XYZ' }, left, top, null], + allowNegativeOffset: true, + }); + } + private relayout() { + if (!this.ready || this.dead) return; + this.viewer.update(); + const anchor = this.location; + const edgeReturn = this.edgeReturn; + const previousTop = this.options.container.scrollTop; + this.changingLayout = true; + try { + this.joined?.apply(); + this.restoreAnchor(anchor); + } finally { + this.changingLayout = false; } + if (edgeReturn) { + const delta = this.options.container.scrollTop - previousTop; + this.edgeReturn = { from: edgeReturn.from + delta, to: edgeReturn.to + delta }; + } + this.viewer.update(); + this.chapters?.refresh(); + this.noteLayers.drawAll(); } turn(direction: number) { if (!this.ready || this.dead) return; if (!this.kindle) { - this.goTo(this.page + direction); + if (this.spread === 'none') this.goTo(this.page + direction); + else this.turnPair(direction); return; } const c = this.options.container, @@ -287,16 +428,26 @@ export class Reader { pixelRatio: devicePixelRatio, direction, edgeReturn: this.edgeReturn, + breaks: this.chapters?.positions(), }); this.edgeReturn = next.edgeReturn; c.scrollTop = next.top; this.viewer.update(); } + // PDF.js knows where each pair starts, and moves one page when only one of the + // two is in view (for example, at a high zoom). + private turnPair(direction: number) { + this.edgeReturn = null; + if (direction > 0) this.viewer.nextPage(); + else this.viewer.previousPage(); + this.viewer.update(); + } + // Home and End of the Kindle: the start or the end of the document, which is a jump. boundary(end: boolean) { if (this.ready) { - this.edgeReturn = null; + const from = this.leave(); this.options.container.scrollTop = end ? this.options.container.scrollHeight : 0; - this.viewer.update(); + this.arrive(from); } } private wheel(e: WheelEvent) { @@ -333,6 +484,179 @@ export class Reader { this.viewer.scrollPageIntoView({ pageNumber: this.viewer.currentPageNumber }); this.viewer.update(); } + // A page chosen on purpose (a thumbnail, a bookmark or note, a typed number, "Ler do início"), + // as opposed to the next or the previous one. It keeps the way back. + jumpTo(page: number) { + if (!this.ready || this.dead || !Number.isFinite(page)) return; + const from = this.leave(); + this.goTo(page); + this.arrive(from); + } + // The highlights of the document, after a note was made, changed or deleted. + setNotes(notes: readonly Note[]) { + if (!this.dead) this.noteLayers.set(notes); + } + // What is selected in the pages, one piece for each page, with where it is in PDF units; null + // when nothing is (see reading/selection-capture.ts). + captureSelection(): SelectionCapture | null { + return readSelection(this.options.viewer, getSelection(), (page) => { + const view = this.viewer.getPageView(page - 1); + return view?.pdfPage ? view.viewport : null; + }); + } + // Shift + arrows: the selection of the pages grows by a word (← →) or by a line (↑ ↓), and starts at + // the first text in view when there is none (see reading/selection-keys.ts). Its end is kept in + // view: the page scrolls to it, and the Kindle turns the screen. Says what came of it. + extendSelection(key: string): Extension { + if (!this.ready || this.dead || !isSelectionKey(key)) return 'edge'; + const result = extendSelection(this.options.container, key); + if (result === 'moved') { + this.showSelectionEnd(); + this.options.onSelect(); + } + return result; + } + private showSelectionEnd() { + const selection = getSelection(), + end = selection && selectionEnd(selection); + if (!end) return; + const c = this.options.container, + area = visibleArea(c); + // The end of the selection is brought to a little inside the edge, so that the line it is on is + // seen with its neighbours. The Kindle does not scroll up and down: it turns the screen, when + // the end is out of this one. + const marginY = Math.min(48, (area.bottom - area.top) / 4), + marginX = Math.min(48, (area.right - area.left) / 4); + let down = 0; + if (this.kindle) { + if (end.bottom > area.bottom + 1) this.turn(1); + else if (end.top < area.top - 1) this.turn(-1); + } else if (end.bottom > area.bottom - marginY) down = end.bottom - (area.bottom - marginY); + else if (end.top < area.top + marginY) down = end.top - (area.top + marginY); + let right = 0; + if (end.right > area.right - marginX) right = end.right - (area.right - marginX); + else if (end.left < area.left + marginX) right = end.left - (area.left + marginX); + if (down || right) c.scrollBy({ left: right, top: down }); + } + // A note chosen on purpose: its highlight, with a few lines above it in view, or its page when + // it has no highlight. Like any other chosen place, it keeps the way back. + jumpToNote(note: Note) { + if (!this.ready || this.dead) return; + const rects = noteRects(note); + const view = rects.length ? this.viewer.getPageView(note.page - 1) : null; + if (!view) return this.jumpTo(note.page); + const from = this.leave(); + const viewport = view.viewport; + let left = Infinity, + top = Infinity; + for (const rect of rects) { + const [ax, ay, bx, by] = viewportRect(viewport, rect); + left = Math.min(left, ax, bx); + top = Math.min(top, ay, by); + } + const room = Math.min(this.options.container.clientHeight * 0.25, 180); + const [x, y] = viewport.convertToPdfPoint(Math.max(0, left - 24), Math.max(0, top - room)); + this.viewer.scrollPageIntoView({ + pageNumber: note.page, + destArray: [null, { name: 'XYZ' }, x, y, null], + allowNegativeOffset: true, + }); + this.viewer.update(); + this.noteLayers.flash(note.id); + // On the page being read, within a screen of the place, there is nothing worth coming back to. + this.arrive(from, this.screenHeight()); + } + // Goes back to where the last jump left, or forward again after going back. Says whether + // there was somewhere to go. + back() { + return this.walk('back'); + } + forward() { + return this.walk('forward'); + } + private walk(direction: 'back' | 'forward') { + const here = this.here(); + if (!here) return false; + const target = direction === 'back' ? this.jumps.back(here) : this.jumps.forward(here); + if (!target) return false; + this.endSearch(); + this.edgeReturn = null; + this.restoreAnchor(target); + this.viewer.update(); + // What was just undone is what can be done again. + this.options.onJump(this.jumps.offer(direction === 'back' ? 'forward' : 'back')); + return true; + } + // The reading anchor as it is now: PDF.js reports it a frame after a scroll, so it is asked + // to look again. + private here(): Position | null { + if (!this.ready || this.dead) return null; + this.viewer.update(); + const l = this.location; + return l ? { pageNumber: l.pageNumber, top: l.top, left: l.left } : null; + } + // The two ends of a jump: the place that is left, taken before the move, and the arrival, + // which keeps that place as the way back if the move did get somewhere else. + private leave() { + this.edgeReturn = null; + return this.here(); + } + private arrive(from: Position | null, near = Infinity) { + const to = this.here(); + if (!from || !to || !this.jumps.record(from, to, near)) return; + this.endSearch(); + this.options.onJump(this.jumps.offer('back')); + } + // How far down a page, in PDF units, a screen of the reader reaches. + private screenHeight() { + return this.options.container.clientHeight / ((this.viewer.currentScale || 1) * (96 / 72)); + } + // The links of the document, its outline and its named actions go through the link service, + // which does not know that they leave the reader's place behind. A destination on the page + // being read, within a screen of the place, is not worth a way back. + private keepWayBack() { + const link = this.link; + const goToDestination = link.goToDestination.bind(link); + const goToPage = link.goToPage.bind(link); + const namedAction = link.executeNamedAction.bind(link); + link.goToDestination = async (dest) => { + const from = this.leave(); + await goToDestination(dest); + this.arrive(from, this.screenHeight()); + }; + link.goToPage = (page) => { + const from = this.leave(); + goToPage(page); + this.arrive(from); + }; + link.executeNamedAction = (action) => { + // "GoBack" and "GoForward" are the PDF's own words for this history. + if (action === 'GoBack') this.back(); + else if (action === 'GoForward') this.forward(); + else if (action === 'FirstPage' || action === 'LastPage') { + const from = this.leave(); + namedAction(action); + this.arrive(from); + } else namedAction(action); + }; + } + // The first result of a search is a jump, from where the reader was when it began. The next + // ones are not: going back leads to where the search began. + private found(state: number) { + const from = this.findFrom; + if (!from || this.dead || (state !== FindState.FOUND && state !== FindState.WRAPPED)) return; + const page = (this.findController.selected?.pageIdx ?? -1) + 1; + if (page < 1 || !this.jumps.record(from, { pageNumber: page })) return; + this.findFrom = null; + this.finding = true; + this.options.onJump(this.jumps.offer('back')); + } + // Another jump, or a step back, closes the search as far as the history goes: its next + // result is a new jump. + private endSearch() { + this.findFrom = null; + this.finding = false; + } zoom(value: string) { if (this.ready) { this.edgeReturn = null; @@ -344,10 +668,11 @@ export class Reader { const scale = Math.min(5, Math.max(0.25, this.viewer.currentScale * Math.pow(1.15, direction))); this.zoom(String(Math.round(scale * 100) / 100)); } - rotate() { + // A quarter turn clockwise; 270 is the one back. + rotate(degrees = 90) { if (this.ready) { const page = this.page; - this.viewer.pagesRotation = (this.viewer.pagesRotation + 90) % 360; + this.viewer.pagesRotation = (this.viewer.pagesRotation + degrees) % 360; this.goTo(page); } } @@ -364,6 +689,8 @@ export class Reader { } find(query: string, again = false, back = false) { this.edgeReturn = null; + // A new search starts from where the reader is; "next" goes on from the place it began. + if (!this.finding && (!again || !this.findFrom)) this.findFrom = this.here(); this.bus.dispatch('find', { source: this, type: again ? 'again' : '', @@ -377,6 +704,7 @@ export class Reader { }); } closeFind() { + this.endSearch(); this.bus.dispatch('findbarclose', { source: this }); } destination(dest: any) { @@ -385,24 +713,67 @@ export class Reader { this.link.goToDestination(dest); } } + // The shape of a page (height over width) as it is shown, with the rotation in effect. + // Until a page is loaded it is the first page's shape, which PDF.js also assumes. + pageRatio(page: number) { + const size = this.sizes.get(page) || this.sizes.get(1); + if (!size) return Math.SQRT2; + return this.viewer.pagesRotation % 180 === 0 ? size[1] / size[0] : size[0] / size[1]; + } + // Draws a page, whole and with the rotation in effect, into `canvas` at `width` pixels. + // Resolves with the page's shape (height over width), or null if it was cancelled: + // by `signal`, or because the book was closed. It does not touch the page views. + async thumbnail(page: number, canvas: HTMLCanvasElement, width: number, signal: AbortSignal) { + if (!this.pdf || this.dead || signal.aborted || !this.joined) return null; + const proxy = await this.pdf.getPage(page); + if (this.dead || signal.aborted) return null; + const rotation = (this.viewer.pagesRotation + proxy.rotate) % 360; + const unit = this.joined.fullViewport(proxy, 1, rotation); + this.sizes.set( + page, + this.viewer.pagesRotation % 180 === 0 ? [unit.width, unit.height] : [unit.height, unit.width], + ); + const viewport = this.joined.fullViewport(proxy, width / unit.width, rotation); + canvas.width = Math.max(1, Math.round(viewport.width)); + canvas.height = Math.max(1, Math.round(viewport.height)); + const task = proxy.render({ canvas, viewport }); + const cancel = () => task.cancel(); + this.thumbnailTasks.add(task); + signal.addEventListener('abort', cancel, { once: true }); + try { + await task.promise; + return unit.height / unit.width; + } catch (error: any) { + if (error?.name === 'RenderingCancelledException') return null; + throw error; + } finally { + signal.removeEventListener('abort', cancel); + this.thumbnailTasks.delete(task); + // Release what the page decoded for this small drawing, such as large images. + // PDF.js keeps it while the page is being drawn elsewhere. + try { + proxy.cleanup(); + } catch { + /* Nothing to free. */ + } + } + } async cover() { if (!this.pdf || this.dead) return null; const page = await this.pdf.getPage(1); if (this.dead) return null; - const viewport = page.getViewport({ scale: 1 }); - const scaled = page.getViewport({ scale: 240 / viewport.width }); - const canvas = document.createElement('canvas'); - canvas.width = Math.ceil(scaled.width); - canvas.height = Math.ceil(scaled.height); - await page.render({ canvas, viewport: scaled }).promise; - const blob = await new Promise((r) => canvas.toBlob(r, 'image/png')); - canvas.width = 0; - canvas.height = 0; - return blob ? new Uint8Array(await blob.arrayBuffer()) : null; + return renderCover(page, (scale) => this.joined!.fullViewport(page, scale)); } async destroy() { this.dead = true; this.ready = false; + this.jumps.clear(); + this.endSearch(); + for (const task of this.thumbnailTasks) task.cancel(); + this.thumbnailTasks.clear(); + this.joined?.destroy(); + this.chapters?.destroy(); + delete this.options.container.dataset.spread; clearTimeout(this.zoomTimer); this.abort.abort(); this.observer.disconnect(); diff --git a/src/reading/chapter-breaks.ts b/src/reading/chapter-breaks.ts new file mode 100644 index 0000000..a727c64 --- /dev/null +++ b/src/reading/chapter-breaks.ts @@ -0,0 +1,117 @@ +import type { PDFDocumentProxy } from 'pdfjs-dist'; +import { isChapterHeading, startsChapter } from './chapters'; +import type { OutlineEntry } from './outline'; + +// Chapter boundaries refer to complete PDF pages, so the preceding page's last +// paragraph is never cropped or removed. No synthetic gaps enter saved positions. +export class ChapterBreaks { + private pages = new Set(); + private inspected = new Set(); + private disposed = false; + private chain = Promise.resolve(); + private tracking = false; + get starts() { + return [...this.pages].sort((a, b) => a - b); + } + track(active: boolean) { + if (active === this.tracking) return; + this.tracking = active; + if (active) this.refresh(); + } + + constructor( + private pdf: PDFDocumentProxy, + private viewer: any, + private container: HTMLElement, + private enabled: () => boolean, + private changed: () => void, + ) {} + + // The chapter starts the outline gives: every top-level entry, and any deeper entry + // whose title is an explicit chapter heading. The pages are already resolved. + outline(entries: readonly OutlineEntry[]) { + const visit = (items: readonly OutlineEntry[], depth: number) => { + for (const item of items) { + if (item.page !== null && (depth === 0 || isChapterHeading(item.title))) + this.pages.add(item.page); + visit(item.items, depth + 1); + } + }; + if (this.disposed) return; + visit(entries, 0); + this.changed(); + } + + rendered(id: number) { + if ((!this.enabled() && !this.tracking) || this.disposed || this.inspected.has(id)) return; + const page = this.viewer.getPageView(id - 1)?.pdfPage; + if (!page) return; + this.inspected.add(id); + this.chain = this.chain.then(async () => { + try { + if (this.disposed) return; + const text = await page.getTextContent(); + if (!this.disposed && startsChapter(text.items) && !this.pages.has(id)) { + this.pages.add(id); + this.changed(); + } + } catch { + /* Image-only pages continue normally; no OCR or network is required. */ + } + }); + } + + positions() { + if (!this.enabled() || this.disposed) return []; + const origin = this.container.getBoundingClientRect().top + this.container.clientTop; + return [...this.pages] + .map((id) => { + const div = this.viewer.getPageView(id - 1)?.div; + if (!div) return 0; + div.dataset.chapterStart = 'true'; + return div.getBoundingClientRect().top - origin + this.container.scrollTop; + }) + .filter((top) => top > 1) + .sort((a, b) => a - b); + } + + update() { + const positions = this.positions(); + const last = positions.at(-1); + const finalPage = this.viewer.getPageView(this.pdf.numPages - 1)?.div; + const origin = this.container.getBoundingClientRect().top + this.container.clientTop; + const end = finalPage + ? finalPage.getBoundingClientRect().bottom - origin + this.container.scrollTop + : 0; + // A short final chapter can still align to the top of its own screen. + const padding = last ? Math.max(0, this.container.clientHeight - (end - last)) : 0; + this.viewer.viewer.style.setProperty('--chapter-end-padding', `${padding}px`); + const next = positions.find((top) => top > this.container.scrollTop + 1); + const visible = + next === undefined ? this.container.clientHeight : next - this.container.scrollTop; + const clipped = Math.max(0, this.container.clientHeight - visible); + // Clip only what belongs to the next chapter, while keeping all preceding + // content in the DOM. Advancing exposes the title at the top of the viewport. + this.container.style.clipPath = clipped > 0 ? `inset(0 0 ${clipped}px 0)` : ''; + this.container.dataset.chapterVisibleHeight = String(this.container.clientHeight - clipped); + } + + refresh() { + this.update(); + if (this.enabled() || this.tracking) { + for ( + let id = Math.max(1, this.viewer.currentPageNumber - 1); + id <= Math.min(this.pdf.numPages, this.viewer.currentPageNumber + 2); + id++ + ) + this.rendered(id); + } + } + + destroy() { + this.disposed = true; + this.container.style.clipPath = ''; + delete this.container.dataset.chapterVisibleHeight; + this.viewer.viewer.style.removeProperty('--chapter-end-padding'); + } +} diff --git a/src/reading/chapters.ts b/src/reading/chapters.ts new file mode 100644 index 0000000..14c7cc4 --- /dev/null +++ b/src/reading/chapters.ts @@ -0,0 +1,27 @@ +// Explicit headings only. A sentence mentioning a previous chapter is not a break. +export function isChapterHeading(value: string) { + const text = value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/^[\s\p{P}]+|[\s\p{P}]+$/gu, '') + .trim(); + if (text.length > 110) return false; + return /^(?:(?:capitulo|chapter|parte|part|livro|book)\s+(?:\d+|[ivxlcdm]+|um|uma|dois|duas|tres|quatro|cinco|seis|sete|oito|nove|dez|onze|doze|treze|catorze|quatorze|quinze|dezesseis|dezassete|dezessete|dezoito|dezenove|vinte|trinta|quarenta|cinquenta|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)\b|prologo\b|epilogo\b|prefacio\b|prologue\b|epilogue\b)/i.test( + text, + ); +} + +export function startsChapter(items: readonly { str?: string; transform?: number[] }[]) { + const lines: { text: string; y: number }[] = []; + for (const item of items) { + if (!item.str?.trim() || !item.transform) continue; + const y = item.transform[5]; + const previous = lines.at(-1); + if (previous && Math.abs(previous.y - y) < 2) previous.text += ` ${item.str}`; + else lines.push({ text: item.str, y }); + if (lines.length > 4) break; + } + // Skip a printed page number, but do not search body paragraphs for keywords. + const first = lines.filter((line) => !/^\s*\d+\s*$/.test(line.text)).slice(0, 2); + return first.some((line) => isChapterHeading(line.text)); +} diff --git a/src/reading/goals.ts b/src/reading/goals.ts new file mode 100644 index 0000000..863b70a --- /dev/null +++ b/src/reading/goals.ts @@ -0,0 +1,144 @@ +import type { Book, BookGoal, DailyGoal, GoalUnit, ReadingDay } from '../types'; + +export function localDay(date = new Date()) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} +export function normalizeGoal(value: unknown): DailyGoal | null { + const goal = value as Partial | null; + return goal && + ['pages', 'chapters'].includes(goal.unit || '') && + Number.isInteger(goal.target) && + goal.target! >= 1 && + goal.target! <= 10000 + ? { unit: goal.unit!, target: goal.target! } + : null; +} +// The goal of a document itself, or null when it has none. A document has one of two settings: a +// goal of its own (`custom`) or none of its own. `inherit` is what is saved now for "no goal of its +// own"; `none` comes from catalogs written before, when it took the document out of the general +// goal, and is read the same way. Nothing that was saved is lost. +export function ownGoal(setting?: BookGoal | null): DailyGoal | null { + return setting?.mode === 'custom' ? normalizeGoal(setting) : null; +} +// The goal that a document is measured by in the reader: its own, or else the general goal. +export function effectiveGoal(book: Book, global: DailyGoal | null) { + return ownGoal(book.readingGoal) ?? global; +} +export function dayRecord(book: Book, day = localDay()): ReadingDay { + return ( + book.readingDays?.[day] || { pages: [], chapters: [], pageAdjustment: 0, chapterAdjustment: 0 } + ); +} +export function dayCount(book: Book, unit: GoalUnit, day = localDay()) { + const record = dayRecord(book, day); + return Math.max( + 0, + record[unit].length + (unit === 'pages' ? record.pageAdjustment : record.chapterAdjustment), + ); +} +// The general goal counts all the reading of the day, in any document: with a goal of its own or +// without, in Removidos, or deleted for good. `books` is the whole list that has days. +export function globalCount(books: Book[], goal: DailyGoal, day = localDay()) { + return books.reduce((sum, b) => sum + dayCount(b, goal.unit, day), 0); +} +// What the goal of a scope says of a day. A document with a goal of its own is measured by it. A +// document without one, and the whole library (no `book`), are measured by the general goal over +// `counted`. +export function goalProgress( + book: Book | undefined, + global: DailyGoal | null, + counted: Book[], + day = localDay(), +) { + const own = !!book && !!ownGoal(book.readingGoal); + const goal = book ? effectiveGoal(book, global) : global; + const count = !goal ? 0 : own ? dayCount(book!, goal.unit, day) : globalCount(counted, goal, day); + return { goal, own, count }; +} +export function withDay(book: Book, day: string, record: ReadingDay) { + // Keep historical totals. Only recent days need page IDs for deduplication. + return Object.fromEntries( + Object.entries({ ...book.readingDays, [day]: record }) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([key, value], index) => [ + key, + index < 31 + ? value + : { + pages: [], + chapters: [], + pageAdjustment: Math.max(0, value.pages.length + value.pageAdjustment), + chapterAdjustment: Math.max(0, value.chapters.length + value.chapterAdjustment), + }, + ]), + ); +} +export type ReadingSample = { + book: string; + day: string; + // First page on screen. In a two-page layout, the first page of the pair. + page: number; + // Last page on screen with `page`. Defaults to `page`, one page per screen. + last?: number; + active: boolean; + atEnd: boolean; + chapters: number[]; +}; +export type ReadingCredit = { pages?: number[]; chapters?: number[] }; +// Adds a credit to a day's record. Returns the same object when nothing is new. +export function withCredit(record: ReadingDay, credit: ReadingCredit): ReadingDay { + const pages = (credit.pages || []).filter((n) => !record.pages.includes(n)), + chapters = (credit.chapters || []).filter((n) => !record.chapters.includes(n)); + return pages.length || chapters.length + ? { ...record, pages: [...record.pages, ...pages], chapters: [...record.chapters, ...chapters] } + : record; +} +// A screen qualifies after eight seconds in an active reader. A screen is one +// page, or the pair of a two-page layout: both pages are in view, so both count. +// Jumps never fill in intermediate pages. Chapters qualify only on an adjacent +// forward transition after a qualifying screen, when a recognized start falls +// inside the screen that qualified, or at the document's final screen. +export class ReadingActivity { + private previous: (ReadingSample & { last: number }) | null = null; + private since = 0; + private qualified = false; + reset() { + this.previous = null; + this.qualified = false; + } + sample(s: ReadingSample, now: number): ReadingCredit { + if (!s.active) { + this.reset(); + return {}; + } + const last = Math.max(s.page, s.last ?? s.page); + const p = this.previous; + const same = p?.book === s.book && p.day === s.day; + const pages: number[] = []; + const chapters: number[] = []; + // The chapter that contains `page` is the one a recognized start after it ends. + const ending = (page: number) => s.chapters.filter((n) => n <= page).at(-1); + const add = (chapter: number | undefined) => { + if (chapter && !chapters.includes(chapter)) chapters.push(chapter); + }; + if (same && this.qualified && s.page === p.last + 1 && s.chapters.includes(s.page)) + add(ending(p.last)); + if (!same || p.page !== s.page) { + this.since = now; + this.qualified = false; + } + this.previous = { ...s, last }; + if (now - this.since >= 8000) { + this.qualified = true; + for (let n = s.page; n <= last; n++) pages.push(n); + // A recognized start on the second page of the screen ends the chapter + // that began before it, which was in view for the whole time. + for (const start of s.chapters) if (start > s.page && start <= last) add(ending(start - 1)); + if (s.atEnd) add(ending(last)); + } + return { + ...(pages.length ? { pages } : {}), + ...(chapters.length ? { chapters } : {}), + }; + } +} diff --git a/src/reading/history.ts b/src/reading/history.ts new file mode 100644 index 0000000..3a4535f --- /dev/null +++ b/src/reading/history.ts @@ -0,0 +1,146 @@ +import type { Book, DailyGoal, Preferences } from '../types'; +import { dayCount, globalCount, localDay, normalizeGoal, ownGoal } from './goals.ts'; + +export function validDay(day: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false; + const [y, m, d] = day.split('-').map(Number); + return y >= 1900 && localDay(new Date(y, m - 1, d, 12)) === day; +} +export function normalizeGoalHistory(value: unknown): Preferences['goalHistory'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .filter(([day, goal]) => validDay(day) && (goal === null || normalizeGoal(goal))) + .map(([day, goal]) => [day, normalizeGoal(goal)]), + ); +} +export function atDay(timeline: Record | undefined, day: string): T | undefined { + const key = Object.keys(timeline || {}) + .filter((key) => key <= day) + .sort() + .at(-1); + return key ? timeline![key] : undefined; +} +export function rememberGoal(prefs: Preferences, day = localDay()) { + const previous = atDay(prefs.goalHistory, day); + if (JSON.stringify(previous) !== JSON.stringify(prefs.dailyGoal)) + prefs.goalHistory = { + ...prefs.goalHistory, + [day]: prefs.dailyGoal ? { ...prefs.dailyGoal } : null, + }; +} +// 'no-own' is a day of one document that had no goal of its own: its totals are shown, and its +// reading still counted in the general goal. +export type DayStatus = 'met' | 'missed' | 'ongoing' | 'none' | 'unknown' | 'no-own'; +export const statusLabels: Record = { + met: 'Meta atingida', + missed: 'Abaixo da meta', + ongoing: 'Em andamento', + none: 'Sem meta', + unknown: 'Meta não registrada', + 'no-own': 'Sem meta própria', +}; +// What the goal of the day says of a scope, by the settings that were in force on that day. +// The whole library (the general goal) counts all the reading of the day, in any document, with a +// goal of its own or without. A document is measured by its own goal when it had one, and when it +// had none only its totals are shown. A setting that was never recorded is 'unknown'. +export function historyDay( + books: Book[], + prefs: Preferences, + day: string, + scope = 'global', + today = localDay(), +) { + const selected = books.find((b) => b.id === scope); + const scopeBooks = selected ? [selected] : books; + const pages = scopeBooks.reduce((sum, b) => sum + dayCount(b, 'pages', day), 0); + const chapters = scopeBooks.reduce((sum, b) => sum + dayCount(b, 'chapters', day), 0); + const setting = selected && atDay(selected.goalHistory, day); + const goal: DailyGoal | null | undefined = selected + ? setting === undefined + ? undefined + : ownGoal(setting) + : atDay(prefs.goalHistory, day); + const count = goal + ? selected + ? dayCount(selected, goal.unit, day) + : globalCount(books, goal, day) + : 0; + const status: DayStatus = + goal === undefined + ? 'unknown' + : goal === null + ? selected + ? 'no-own' + : 'none' + : count >= goal.target + ? 'met' + : day === today + ? 'ongoing' + : 'missed'; + const recorded = + !!scopeBooks.some((b) => b.readingDays?.[day]) || + (selected ? setting !== undefined : atDay(prefs.goalHistory, day) !== undefined); + // `own`: the goal is the one of the document, not the general goal. + const own = !!selected && !!goal; + return { day, pages, chapters, goal, count, status, own, recorded, books: scopeBooks }; +} +export function monthDays(month: string) { + const [y, m] = month.split('-').map(Number); + return Array.from({ length: new Date(y, m, 0).getDate() }, (_, i) => + localDay(new Date(y, m - 1, i + 1, 12)), + ); +} +const parts = (day: string) => day.split('-').map(Number) as [number, number, number]; +const addDays = (day: string, count: number) => { + const [y, m, d] = parts(day); + return localDay(new Date(y, m - 1, d + count, 12)); +}; +// The same day of another month, or the last day of it when it has fewer days. +const addMonths = (day: string, count: number) => { + const [y, m, d] = parts(day); + return localDay(new Date(y, m - 1 + count, Math.min(d, new Date(y, m + count, 0).getDate()), 12)); +}; +export type CalendarStep = { day: string } | { refused: 'past' | 'future' } | null; +// Where a key takes the focus in the calendar of a month (the pattern of a grid, from the WAI-ARIA +// Authoring Practices): ← and → one day, ↑ and ↓ one week, Home and End the start and the end of the +// week as the calendar shows it (Monday to Sunday, inside the month), Page Up and Page Down the same +// day of the month before and after. A day after `today`, or in a month before the first one with a +// record, is refused and the answer says which; null when the key is not one of these. +export function calendarStep(day: string, key: string, today: string, first: string): CalendarStep { + const month = day.slice(0, 7); + const [y, m, d] = parts(day); + // 0 is the Monday, as in the header of the calendar. + const weekday = (new Date(y, m - 1, d, 12).getDay() + 6) % 7; + let target: string; + if (key === 'ArrowLeft') target = addDays(day, -1); + else if (key === 'ArrowRight') target = addDays(day, 1); + else if (key === 'ArrowUp') target = addDays(day, -7); + else if (key === 'ArrowDown') target = addDays(day, 7); + else if (key === 'Home') { + target = addDays(day, -weekday); + if (target.slice(0, 7) < month) target = `${month}-01`; + } else if (key === 'End') { + target = addDays(day, 6 - weekday); + if (target.slice(0, 7) > month) target = monthDays(month).at(-1)!; + if (target > today) target = today; + } else if (key === 'PageUp') target = addMonths(day, -1); + else if (key === 'PageDown') { + target = addMonths(day, 1); + // The current month stops at today. + if (target.slice(0, 7) === today.slice(0, 7) && target > today) target = today; + } else return null; + if (target > today) return { refused: 'future' }; + if (target.slice(0, 7) < first.slice(0, 7)) return { refused: 'past' }; + return { day: target }; +} +export function firstHistoryDay(books: Book[], prefs: Preferences, today = localDay()) { + return [ + ...Object.keys(prefs.goalHistory), + ...books.flatMap((b) => [ + ...Object.keys(b.goalHistory || {}), + ...Object.keys(b.readingDays || {}), + ]), + today, + ].sort()[0]; +} diff --git a/src/reading/joined-pages.ts b/src/reading/joined-pages.ts new file mode 100644 index 0000000..7b9fc51 --- /dev/null +++ b/src/reading/joined-pages.ts @@ -0,0 +1,251 @@ +import { setLayerDimensions, type PDFDocumentProxy, type PDFPageProxy } from 'pdfjs-dist'; +import { contentRows, containedBox, validBox, type Box } from './page-margins'; + +type Margins = { original: Box; crop: Box; rotation: number; unit: number }; +const CACHE_PREFIX = 'folio.page-margins.v1.'; + +// Small PDF.js adapter: its page views use the display CropBox for rendering, +// selection, search and PDF-coordinate navigation. The source PDF stays intact. +export class JoinedPages { + private records = new Map(); + private pages = new Map(); + private original = new Map(); + private pending = new Set(); + private chain = Promise.resolve(); + private initialized = false; + private disposed = false; + private cacheKey: string; + + constructor( + pdf: PDFDocumentProxy, + hash: string, + private viewer: any, + private enabled: () => boolean, + private relayout: () => void, + ) { + this.cacheKey = CACHE_PREFIX + hash; + try { + const saved = JSON.parse(localStorage.getItem(this.cacheKey) || '{}'); + for (const [id, entry] of (Array.isArray(saved.pages) ? saved.pages : []).slice(-1000)) { + if ( + Number.isInteger(id) && + id >= 1 && + id <= pdf.numPages && + validBox(entry?.original) && + validBox(entry?.crop) && + containedBox(entry.crop, entry.original) && + [0, 90, 180, 270].includes(entry.rotation) && + Number.isFinite(entry.unit) && + entry.unit > 0 + ) + this.records.set(id, entry); + } + } catch { + /* A disposable cache must never prevent reading. */ + } + const getPage = pdf.getPage.bind(pdf); + pdf.getPage = async (id: number) => { + const page = await getPage(id); + if (!this.disposed) { + this.pages.set(id, page); + if (!this.original.has(id)) this.original.set(id, [...page.view] as Box); + this.setBox(id, page); + } + return page; + }; + } + + private setBox(id: number, page: PDFPageProxy) { + const box = this.initialized && this.enabled() ? this.records.get(id)?.crop : null; + // Give new viewports a fresh display box. Mutating the old array in place + // would also alter existing viewports without recalculating their dimensions. + // The proxy's source page info, PDF bytes and library metadata stay intact. + Object.defineProperty(page, 'view', { + value: [...(box || this.original.get(id)!)], + configurable: true, + }); + } + + // The whole page, as in the document, even where the display box is cropped. `rotation` + // is the total (the page's own plus the reader's); without it, the page's own. + fullViewport(page: PDFPageProxy, scale: number, rotation?: number) { + const viewport = page.getViewport(rotation === undefined ? { scale } : { scale, rotation }); + viewport.viewBox = this.original.get(page.pageNumber) || [...page.view]; + return viewport.clone(); + } + + apply() { + this.initialized = true; + for (const [id, record] of this.records) { + const view = this.viewer.getPageView(id - 1); + if (!view) continue; + const box = this.enabled() ? record.crop : record.original; + const page = this.pages.get(id); + if (page) this.setBox(id, page); + if (view.viewport.viewBox.some((value: number, i: number) => value !== box[i])) { + if (view.pdfPage) view.setPdfPage(view.pdfPage); + else { + // Restore cached placeholder sizes without fetching/rasterizing the + // preceding pages of a long book just to resume in its middle. + view.viewport.viewBox = [...box]; + view.viewport.userUnit = record.unit; + view.pdfPageRotate = record.rotation; + view.update({ scale: view.scale }); + } + // PDFPageView skips dimension updates when rotation is unchanged. + // A changed display box still needs new CSS dimensions at the same zoom. + setLayerDimensions(view.div, view.viewport, true, false); + } + view.div.dataset.margins = 'ready'; + } + if (this.enabled()) { + for ( + let id = Math.max(1, this.viewer.currentPageNumber - 1); + id <= Math.min(this.viewer.pagesCount, this.viewer.currentPageNumber + 2); + id++ + ) { + this.rendered(id); + } + } + } + + rendered(id: number) { + const view = this.viewer.getPageView(id - 1); + if ( + !this.enabled() || + this.disposed || + this.records.has(id) || + this.pending.has(id) || + !view?.canvas?.width || + !view.pdfPage || + this.viewer.pagesRotation !== 0 + ) + return; + this.pending.add(id); + this.chain = this.chain.then(async () => { + try { + if ( + this.disposed || + !this.enabled() || + !view.canvas?.width || + this.viewer.pagesRotation !== 0 + ) + return; + const entry = await this.measure(view); + if (this.disposed || !entry) return; + this.records.set(id, entry); + this.save(); + if (this.enabled()) this.relayout(); + } catch (error) { + console.warn('Page margin analysis kept the original page:', error); + } finally { + this.pending.delete(id); + } + }); + } + + private async measure(view: any): Promise { + const page: PDFPageProxy = view.pdfPage; + const original = this.original.get(page.pageNumber)!; + const record = { + original, + crop: [...original] as Box, + rotation: page.rotate, + unit: page.userUnit, + }; + const viewport = view.viewport.clone() as ReturnType; + const canvas: HTMLCanvasElement = view.canvas; + const scale = Math.min(1, 640 / Math.max(canvas.width, canvas.height)); + const sample = document.createElement('canvas'); + sample.width = Math.max(1, Math.round(canvas.width * scale)); + sample.height = Math.max(1, Math.round(canvas.height * scale)); + const ctx = sample.getContext('2d', { willReadFrequently: true })!; + try { + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, sample.width, sample.height); + ctx.drawImage(canvas, 0, 0, sample.width, sample.height); + const rows = contentRows( + ctx.getImageData(0, 0, sample.width, sample.height).data, + sample.width, + sample.height, + ); + if (!rows) return record; + let top = (rows.first / sample.height) * viewport.height; + let bottom = (rows.last / sample.height) * viewport.height; + const include = (rect: number[]) => { + const a = viewport.convertToViewportPoint(rect[0], rect[1]); + const b = viewport.convertToViewportPoint(rect[2], rect[3]); + top = Math.min(top, a[1], b[1]); + bottom = Math.max(bottom, a[1], b[1]); + }; + // Keep even very faint text that could disappear in a small bitmap, as + // well as annotation/widget hit areas. Rotated/skewed text stays uncropped. + const text = await page.getTextContent(); + for (const item of text.items) { + if (!('str' in item) || !item.str.trim()) continue; + const [a, b, c, d, x, y] = item.transform; + if (Math.abs(b) > 0.001 || Math.abs(c) > 0.001 || a <= 0 || d <= 0) return record; + const h = Math.max(item.height, Math.abs(d)); + const style = text.styles[item.fontName]; + include([ + x, + y + Math.min(-0.25, style?.descent ?? -0.25) * h, + x + item.width, + y + Math.max(1, style?.ascent ?? 1) * h, + ]); + } + for (const annotation of await page.getAnnotations()) { + if (Array.isArray(annotation.rect)) include(annotation.rect); + } + const padding = Math.max( + 6 * viewport.scale * page.userUnit, + (viewport.height / sample.height) * 2, + ); + top = Math.max(0, Math.min(viewport.height * 0.45, top - padding)); + bottom = Math.min(viewport.height, Math.max(viewport.height * 0.55, bottom + padding)); + if (top < padding) top = 0; + if (viewport.height - bottom < padding) bottom = viewport.height; + const p = viewport.convertToPdfPoint(0, top); + const q = viewport.convertToPdfPoint(viewport.width, bottom); + const crop: Box = [ + Math.min(p[0], q[0]), + Math.min(p[1], q[1]), + Math.max(p[0], q[0]), + Math.max(p[1], q[1]), + ]; + record.crop = crop.map((value, i) => + i < 2 ? Math.max(original[i], value) : Math.min(original[i], value), + ) as Box; + return record; + } finally { + sample.width = sample.height = 0; + } + } + + private save() { + try { + localStorage.setItem( + this.cacheKey, + JSON.stringify({ + used: Date.now(), + pages: [...this.records].slice(-1000), + }), + ); + const keys = Object.keys(localStorage).filter((key) => key.startsWith(CACHE_PREFIX)); + if (keys.length > 8) { + keys.sort( + (a, b) => + (JSON.parse(localStorage.getItem(a) || '{}').used || 0) - + (JSON.parse(localStorage.getItem(b) || '{}').used || 0), + ); + for (const key of keys.slice(0, keys.length - 8)) localStorage.removeItem(key); + } + } catch { + /* Storage can be full/unavailable; the current reading still works. */ + } + } + + destroy() { + this.disposed = true; + } +} diff --git a/src/reading/jumps.ts b/src/reading/jumps.ts new file mode 100644 index 0000000..4954373 --- /dev/null +++ b/src/reading/jumps.ts @@ -0,0 +1,90 @@ +// The way back after a jump. A jump is a command that takes the reader somewhere else in the +// document without reading what is between: the outline, a link, the first result of a search, +// a bookmark or note, a thumbnail, a typed page number, Home/End and "Ler do início". Scrolling +// and turning pages are not jumps. +// +// A position is the reading anchor in PDF coordinates, as PDF.js reports it. It does not depend +// on the zoom, the rotation or the layout, so it is still right after any of them changes. +export type Position = { pageNumber: number; top: number; left: number }; + +// Which way the pill that follows a jump is pointing, and the pages at the two ends. +export type JumpOffer = { back: number | null; forward: number | null; show: 'back' | 'forward' }; + +// The most positions kept in each direction. The oldest ones are forgotten first. +export const MAX_JUMPS = 50; +// Two positions on the same page this close (in PDF units, about a third of an inch) are the +// same place: going there would not change what is on the screen. +export const SAME_PLACE = 24; + +const valid = (p: Partial): p is Position => + Number.isInteger(p.pageNumber) && + (p.pageNumber as number) >= 1 && + Number.isFinite(p.top) && + Number.isFinite(p.left); +const copy = (p: Position): Position => ({ pageNumber: p.pageNumber, top: p.top, left: p.left }); + +export function samePlace(a: Position, b: Position) { + return a.pageNumber === b.pageNumber && Math.abs(a.top - b.top) <= SAME_PLACE; +} + +// Two stacks, like the history of a browser: where to go back to and where to come forward to. +// They only live as long as the document is open. +export class JumpHistory { + private past: Position[] = []; + private future: Position[] = []; + // Where the next step back goes, and where the next step forward goes. + get previous() { + return this.past.at(-1) ?? null; + } + get next() { + return this.future.at(-1) ?? null; + } + clear() { + this.past = []; + this.future = []; + } + // A jump from `from` that landed on `to`. It is not worth keeping when it stays on the same + // page, within `near` PDF units of where it was (by default, anywhere on the page): nothing + // was left behind. A kept jump forgets what was ahead, as a new path does in a browser, and + // does not repeat the position that is already the last one. Says whether it was kept. + record(from: Position, to: { pageNumber: number; top?: number }, near = Infinity) { + if (!valid(from) || !Number.isInteger(to.pageNumber)) return false; + const close = + near === Infinity || (to.top !== undefined && Math.abs(to.top - from.top) <= near); + if (to.pageNumber === from.pageNumber && close) return false; + this.future = []; + this.push(this.past, from); + return true; + } + // The position to go back to, from where the reader is now, or null when there is none. + // `here` goes to the other stack, so that the step can be undone. + back(here: Position) { + return this.step(this.past, this.future, here); + } + forward(here: Position) { + return this.step(this.future, this.past, here); + } + // What the pill offers: the pages at both ends, and the side it shows. + offer(show: JumpOffer['show']): JumpOffer { + return { + back: this.previous?.pageNumber ?? null, + forward: this.next?.pageNumber ?? null, + show, + }; + } + private step(from: Position[], to: Position[], here: Position) { + if (!valid(here)) return null; + // A place where the reader already is would change nothing: it is dropped, not visited. + while (from.length && samePlace(from[from.length - 1], here)) from.pop(); + const target = from.pop(); + if (!target) return null; + this.push(to, here); + return copy(target); + } + private push(stack: Position[], position: Position) { + const last = stack.at(-1); + if (last && samePlace(last, position)) return; + stack.push(copy(position)); + if (stack.length > MAX_JUMPS) stack.shift(); + } +} diff --git a/src/reading/note-colors.ts b/src/reading/note-colors.ts new file mode 100644 index 0000000..75b0da2 --- /dev/null +++ b/src/reading/note-colors.ts @@ -0,0 +1,38 @@ +import type { Note, NoteColor } from '../types'; + +// The four colours of a highlight. The names are saved in the catalog (and checked by +// src-tauri/src/notes.rs), so they do not change; the words are only for the reader. +export const NOTE_COLORS: readonly NoteColor[] = ['yellow', 'green', 'blue', 'pink']; +export const DEFAULT_NOTE_COLOR: NoteColor = 'yellow'; +export const COLOR_LABELS: Record = { + yellow: 'Amarelo', + green: 'Verde', + blue: 'Azul', + pink: 'Rosa', +}; + +export const isNoteColor = (value: unknown): value is NoteColor => + NOTE_COLORS.includes(value as NoteColor); + +// The colour a note is drawn with; a highlight from before colours (or a damaged value) is yellow. +export const colorOf = (note: Pick): NoteColor => + isNoteColor(note.color) ? note.color : DEFAULT_NOTE_COLOR; + +// The last colour the reader used, which the shortcut and the next highlight start from. It is +// a convenience: a storage that fails gives the default, and never stops a highlight. +const COLOR_KEY = 'folio.highlight-color'; +export function rememberedColor(storage: Pick = localStorage): NoteColor { + try { + const saved = storage.getItem(COLOR_KEY); + return isNoteColor(saved) ? saved : DEFAULT_NOTE_COLOR; + } catch { + return DEFAULT_NOTE_COLOR; + } +} +export function rememberColor(color: NoteColor, storage: Pick = localStorage) { + try { + storage.setItem(COLOR_KEY, color); + } catch { + /* The colour only comes back as the default. */ + } +} diff --git a/src/reading/note-layer.ts b/src/reading/note-layer.ts new file mode 100644 index 0000000..1e1cb8b --- /dev/null +++ b/src/reading/note-layer.ts @@ -0,0 +1,208 @@ +import type { Note } from '../types'; +import { icon } from '../icons'; +import { esc } from '../ui/dom'; +import { colorOf } from './note-colors'; +import { noteRects } from './note-rects'; + +// The part of a page view that the highlights need: the element of the page, the page that PDF.js +// loaded for it (until it has one, the size of the page is a guess) and the viewport it is shown +// with, which already has the zoom, the rotation and the display box (see joined-pages.ts). +type PageView = { + div: HTMLElement; + pdfPage?: unknown; + viewport: { + width: number; + height: number; + rotation: number; + viewBox: number[]; + convertToViewportPoint(x: number, y: number): number[]; + }; +}; +// A rectangle in PDF units as it is shown now. PDF.js 6 has no `convertToViewportRectangle`, so +// both corners are converted, as joined-pages.ts does. +export function viewportRect( + viewport: Pick, + [x1, y1, x2, y2]: number[], +) { + return [...viewport.convertToViewportPoint(x1, y1), ...viewport.convertToViewportPoint(x2, y2)]; +} +type Pages = { getPageView(index: number): PageView | undefined }; + +// A highlight was clicked. `anchor` is what the menu hangs from; `at` is where the pointer was +// when the click was on the text itself, and null when it was on a mark (which has a button). +export type NoteOpen = ( + id: string, + anchor: HTMLElement, + at: { x: number; y: number } | null, +) => void; + +const percent = (fraction: number) => `${Math.round(fraction * 10_000) / 100}%`; +const excerpt = (text: string) => { + const line = text.replace(/\s+/g, ' ').trim(); + return line.length > 140 ? `${line.slice(0, 139)}…` : line; +}; +// A click that moved this far (pixels) was a drag, not a click. +const DRAG = 5; +// How long the highlight that a jump arrived at stays marked, in milliseconds. +const FLASH = 1800; + +// The highlights, drawn in one layer for each page, inside the page of PDF.js. The layer ignores +// the pointer (so the text can be selected and the links followed): a click on a highlighted +// passage is found by where it fell, and only the small marks of the notes with text are buttons. +// The rectangles are saved in PDF units, which `viewportRect` turns into the +// page as it is shown now, and are placed in percent of the page, so that a zoom that PDF.js is +// still drawing leaves them where they belong. PDF.js clears what it does not know whenever it +// redraws a page, so the layers are drawn again at each of these moments (see reader.ts). +export class NoteLayers { + private byPage = new Map(); + private drawn = new WeakMap(); + private down: { x: number; y: number } | null = null; + private flashing: string | null = null; + private flashTimer: ReturnType | undefined; + private frame = 0; + + constructor( + private pages: Pages, + private container: HTMLElement, + private open: NoteOpen, + signal: AbortSignal, + ) { + const on = ( + type: K, + listener: (event: HTMLElementEventMap[K]) => void, + ) => container.addEventListener(type, listener, { signal }); + on('pointerdown', (e) => (this.down = { x: e.clientX, y: e.clientY })); + on('click', (e) => this.click(e)); + on('pointermove', (e) => this.hover(e)); + on('pointerleave', () => container.removeAttribute('data-note-hover')); + signal.addEventListener('abort', () => { + clearTimeout(this.flashTimer); + cancelAnimationFrame(this.frame); + }); + } + + // The notes of the document now. Only those with rectangles are drawn. + set(notes: readonly Note[]) { + const before = new Set(this.byPage.keys()); + this.byPage = new Map(); + for (const note of notes) { + if (!Number.isInteger(note.page) || note.page < 1 || !noteRects(note).length) continue; + this.byPage.set(note.page, [...(this.byPage.get(note.page) ?? []), note]); + } + for (const page of new Set([...before, ...this.byPage.keys()])) this.draw(page); + } + + drawAll() { + for (const page of this.byPage.keys()) this.draw(page); + } + + // Draws the layer of one page, if it has highlights and PDF.js has its page; takes the layer + // away when it has none. Nothing happens when what would be drawn is already there. + draw(page: number) { + const view = this.pages.getPageView(page - 1); + const div = view?.div; + if (!view || !div) return; + const notes = this.byPage.get(page); + const old = div.querySelector(':scope > .note-layer'); + if (!notes?.length) { + old?.remove(); + return; + } + // Until the page is loaded its size is the first page's, and the rectangles would be wrong. + if (!view.pdfPage) return; + const { viewport } = view, + { width, height } = viewport; + if (!(width > 0 && height > 0)) return; + let marks = '', + pins = ''; + for (const note of notes) { + const color = colorOf(note); + let first: { top: number; bottom: number } | null = null; + for (const rect of noteRects(note)) { + const [ax, ay, bx, by] = viewportRect(viewport, rect); + const left = Math.min(ax, bx), + top = Math.min(ay, by), + right = Math.max(ax, bx), + bottom = Math.max(ay, by); + marks += `
    `; + if (!first || top < first.top) first = { top, bottom }; + } + const text = excerpt(note.text ?? ''); + // A note with text has a small mark in the margin, at the height of its first line. + if (text && first) + pins += ``; + } + const html = `
    ${pins}
    `; + const layer = old ?? document.createElement('div'); + if (this.drawn.get(layer) !== html || !old) { + layer.className = 'note-layer'; + layer.innerHTML = html; + this.drawn.set(layer, html); + this.markFlash(layer); + } + // The last child of the page: above its text, so that nothing of PDF.js hides it. + if (div.lastElementChild !== layer) div.append(layer); + } + + // Marks the highlight that a jump arrived at for a moment. + flash(id: string) { + this.flashing = id; + this.markFlash(this.container); + clearTimeout(this.flashTimer); + this.flashTimer = setTimeout(() => { + this.flashing = null; + this.markFlash(this.container); + }, FLASH); + } + + private markFlash(within: HTMLElement) { + for (const mark of within.querySelectorAll('.note-mark')) + mark.toggleAttribute('data-flash', !!this.flashing && mark.dataset.note === this.flashing); + } + + // The note whose highlight is under a point of the window, on the page of `target`. When several + // overlap, the one drawn last (the newest) wins. + private at(target: Element, x: number, y: number) { + const marks = target.closest('.page')?.querySelectorAll('.note-mark'); + if (!marks) return null; + for (let i = marks.length - 1; i >= 0; i--) { + const box = marks[i].getBoundingClientRect(); + if (x >= box.left - 1 && x <= box.right + 1 && y >= box.top - 1 && y <= box.bottom + 1) + return marks[i].dataset.note ?? null; + } + return null; + } + + private click(e: MouseEvent) { + const target = e.target; + if (!(target instanceof Element) || e.button !== 0) return; + const pin = target.closest('.note-pin'); + if (pin) { + if (pin.dataset.note) this.open(pin.dataset.note, pin, null); + return; + } + // A link of the PDF is the link's, and a selection that has just been made is not a click. + if (target.closest('.annotationLayer') || e.detail > 1) return; + if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; + const down = this.down; + if (down && Math.hypot(e.clientX - down.x, e.clientY - down.y) > DRAG) return; + const selection = getSelection(); + if (selection && !selection.isCollapsed) return; + const id = this.at(target, e.clientX, e.clientY); + if (id) this.open(id, this.container, { x: e.clientX, y: e.clientY }); + } + + // The pointer over a highlighted passage says that it can be clicked. + private hover(e: PointerEvent) { + if (e.buttons || this.frame) return; + const { target, clientX, clientY } = e; + this.frame = requestAnimationFrame(() => { + this.frame = 0; + const over = + target instanceof Element && + !target.closest('.annotationLayer, .note-pin') && + this.at(target, clientX, clientY) !== null; + this.container.toggleAttribute('data-note-hover', over); + }); + } +} diff --git a/src/reading/note-rects.ts b/src/reading/note-rects.ts new file mode 100644 index 0000000..ba23a74 --- /dev/null +++ b/src/reading/note-rects.ts @@ -0,0 +1,110 @@ +import type { Note, NoteRect } from '../types'; + +// Geometry of the highlights, as numbers only, so that it can be checked without a window. +// Two spaces are used: the window (a `Box`, in pixels) while a selection is read, and the PDF +// (a `NoteRect`, in page units) for what is saved, which does not depend on the zoom or rotation. +export type Box = { left: number; top: number; right: number; bottom: number }; + +// The most rectangles of one highlight; the catalog refuses more (src-tauri/src/notes.rs). +export const MAX_NOTE_RECTS = 500; + +const finiteBox = (b: Box) => [b.left, b.top, b.right, b.bottom].every(Number.isFinite); + +// The rectangles of a note that can be drawn: four finite numbers each. A damaged value is +// left out instead of stopping the page. +export function noteRects(note: Pick): NoteRect[] { + if (!Array.isArray(note.rects)) return []; + return note.rects + .filter((r) => Array.isArray(r) && r.length === 4 && r.every(Number.isFinite)) + .slice(0, MAX_NOTE_RECTS); +} + +export const isHighlight = (note: Pick) => noteRects(note).length > 0; + +// A rectangle between two corners, whichever way they were given (a rotated page turns them), +// with two decimals: a hundredth of a point is far below what the eye can tell. +export function rectBetween(x1: number, y1: number, x2: number, y2: number): NoteRect { + const round = (n: number) => Math.round(n * 100) / 100; + return [ + round(Math.min(x1, x2)), + round(Math.min(y1, y2)), + round(Math.max(x1, x2)), + round(Math.max(y1, y2)), + ]; +} + +// The part of `box` that is inside `within`, or null when nothing is. +export function clipBox(box: Box, within: Box): Box | null { + const clipped = { + left: Math.max(box.left, within.left), + top: Math.max(box.top, within.top), + right: Math.min(box.right, within.right), + bottom: Math.min(box.bottom, within.bottom), + }; + return clipped.right > clipped.left && clipped.bottom > clipped.top ? clipped : null; +} + +// What a range of text reports is one rectangle for each piece of text and each element in it, +// several on a line and some repeated. A highlight wants one for each line: the rectangles +// that share a line (more than half of the shorter one's height in common) and touch, or are +// no further apart than `gap` times the height of the line (a space between two words), are one. +// The result goes from the top down and, in a line, from left to right. +export function mergeLineRects(boxes: readonly Box[], gap = 0.75): Box[] { + const usable = boxes + .filter((b) => finiteBox(b) && b.right - b.left >= 1 && b.bottom - b.top >= 1) + .sort((a, b) => a.top - b.top || a.left - b.left); + const rows: { top: number; bottom: number; items: Box[] }[] = []; + for (const box of usable) { + const row = rows.find((r) => { + const shared = Math.min(r.bottom, box.bottom) - Math.max(r.top, box.top); + return shared > 0.5 * Math.min(box.bottom - box.top, r.bottom - r.top); + }); + if (row) { + row.items.push(box); + row.top = Math.min(row.top, box.top); + row.bottom = Math.max(row.bottom, box.bottom); + } else rows.push({ top: box.top, bottom: box.bottom, items: [box] }); + } + const lines: Box[] = []; + for (const row of rows) { + const reach = gap * (row.bottom - row.top); + let open: Box | null = null; + for (const item of row.items.sort((a, b) => a.left - b.left)) { + if (open && item.left - open.right <= reach) { + open = { + left: Math.min(open.left, item.left), + top: Math.min(open.top, item.top), + right: Math.max(open.right, item.right), + bottom: Math.max(open.bottom, item.bottom), + }; + } else { + if (open) lines.push(open); + open = { ...item }; + } + } + if (open) lines.push(open); + } + return lines.sort((a, b) => a.top - b.top || a.left - b.left); +} + +// How high on the page a highlight is: its highest edge (a larger y is higher in PDF space). +// Null for a note that is not a highlight. +export function noteTop(note: Pick): number | null { + const rects = noteRects(note); + return rects.length ? Math.max(...rects.map((r) => Math.max(r[1], r[3]))) : null; +} + +// Reading order, the same one the export uses (src-tauri/src/notes.rs): by page; on a page, the +// highlights from the top down and then the notes that point at no place; and by the time they +// were made when nothing else tells them apart. +export function noteOrder(a: Note, b: Note): number { + if (a.page !== b.page) return a.page - b.page; + const x = noteTop(a), + y = noteTop(b); + if (x !== null && y !== null) { + if (x !== y) return y - x; + } else if (x !== null) return -1; + else if (y !== null) return 1; + if (a.created !== b.created) return a.created - b.created; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} diff --git a/src/reading/outline.ts b/src/reading/outline.ts new file mode 100644 index 0000000..791fe1c --- /dev/null +++ b/src/reading/outline.ts @@ -0,0 +1,137 @@ +// The PDF outline (sumário) with every destination resolved to a page number. The +// chapter starts and the side bar share this one resolution. +export interface OutlineEntry { + title: string; + // Null when the destination is external, broken or points outside the document. + page: number | null; + // What PDF.js needs to navigate; kept as it came from the document. + dest: unknown; + items: OutlineEntry[]; +} + +// The part of PDFDocumentProxy that the resolution needs. +export interface OutlineSource { + numPages: number; + getDestination(id: string): Promise; + getPageIndex(ref: any): Promise; +} + +// The 1-based page of a destination: an explicit array, or a name to look up. A broken +// or external destination is not an error; it just has no page. +export async function destinationPage(source: OutlineSource, dest: unknown) { + try { + const target = typeof dest === 'string' ? await source.getDestination(dest) : dest; + if (!Array.isArray(target)) return null; + const first = target[0]; + const index = + typeof first === 'object' && first !== null ? await source.getPageIndex(first) : first; + return Number.isInteger(index) && index >= 0 && index < source.numPages + ? (index as number) + 1 + : null; + } catch { + return null; + } +} + +// Resolves the whole tree at once. Each destination is looked up one time. +export async function resolveOutline(source: OutlineSource, raw: unknown): Promise { + const visit = (items: unknown): Promise => + Promise.all( + (Array.isArray(items) ? items : []).map(async (item: any) => ({ + title: typeof item?.title === 'string' ? item.title : '', + dest: item?.dest ?? null, + page: await destinationPage(source, item?.dest), + items: await visit(item?.items), + })), + ); + return visit(raw); +} + +// A row of the tree in reading order. `id` is the path of indexes, such as "2.0.1". +export interface OutlineRow { + id: string; + entry: OutlineEntry; + depth: number; + // From the top level down to the parent. + ancestors: string[]; + children: string[]; +} + +export function flattenOutline(entries: readonly OutlineEntry[]): OutlineRow[] { + const rows: OutlineRow[] = []; + const visit = (items: readonly OutlineEntry[], prefix: string, ancestors: string[]) => { + items.forEach((entry, index) => { + const id = prefix ? `${prefix}.${index}` : String(index); + const row: OutlineRow = { id, entry, depth: ancestors.length, ancestors, children: [] }; + rows.push(row); + visit(entry.items, id, [...ancestors, id]); + row.children = entry.items.map((_, i) => `${id}.${i}`); + }); + }; + visit(entries, '', []); + return rows; +} + +// The section being read: the last item that starts on or before the page. In reading +// order a deeper item comes after its parent, so a subsection beats its chapter, and the +// next chapter beats the subsections of the previous one. Items without a page never +// match, and an outline out of order still gives the nearest start. +export function currentSection(rows: readonly OutlineRow[], page: number): string | null { + let best: OutlineRow | null = null; + for (const row of rows) { + const start = row.entry.page; + if (start === null || start > page) continue; + if (!best || start >= best.entry.page!) best = row; + } + return best ? best.id : null; +} + +// How the user folded the tree by hand: true opens a branch and false closes it. What +// is not here follows the default, which opens only the branches that hold the section. +export type Folds = ReadonlyMap; + +function pathTo(rows: readonly OutlineRow[], id: string | null): string[] { + const row = id === null ? undefined : rows.find((r) => r.id === id); + return row ? row.ancestors : []; +} + +// The branches that show their children now. +export function expandedBranches( + rows: readonly OutlineRow[], + folds: Folds, + current: string | null, +): Set { + const automatic = new Set(pathTo(rows, current)); + const open = new Set(); + for (const row of rows) + if (row.children.length && (folds.get(row.id) ?? automatic.has(row.id))) open.add(row.id); + return open; +} + +// Opens a closed branch or closes an open one. +export function toggleBranch( + rows: readonly OutlineRow[], + folds: Folds, + id: string, + current: string | null, +): Folds { + const next = new Map(folds); + next.set(id, !expandedBranches(rows, folds, current).has(id)); + return next; +} + +// Moving into a section shows it: the branches that hold it forget a manual close. +export function revealSection( + rows: readonly OutlineRow[], + folds: Folds, + current: string | null, +): Folds { + const next = new Map(folds); + for (const id of pathTo(rows, current)) next.delete(id); + return next; +} + +// The rows that can be seen: every branch above them is open. +export function visibleRows(rows: readonly OutlineRow[], open: ReadonlySet): OutlineRow[] { + return rows.filter((row) => row.ancestors.every((id) => open.has(id))); +} diff --git a/src/reading/pace.ts b/src/reading/pace.ts new file mode 100644 index 0000000..c291c40 --- /dev/null +++ b/src/reading/pace.ts @@ -0,0 +1,165 @@ +import type { PaceEntry } from '../types'; + +// Seconds a page must hold the reader to count as read. Faster flips are skimming. +export const MIN_PAGE_SECONDS = 3; +// The time credited to one page is capped, so a slow page cannot skew the pace. +export const MAX_PAGE_SECONDS = 240; +// A page held longer than this is a pause (a coffee, a phone call), not reading. +export const IDLE_PAGE_SECONDS = 600; +// A forward step larger than this is a jump. Kindle screens can skip a page or two. +export const MAX_FORWARD_STEP = 3; +// Pages that must be measured in a book before an estimate is shown. +export const MIN_SAMPLES = 5; +// Books whose pace is kept. The least recently read are forgotten first. +export const MAX_BOOKS = 50; +const MAX_SAMPLES = 1000; +const MAX_ID_LENGTH = 100; +// Samples taken further apart than this mean the timer paused (sleep, throttling). +const MAX_GAP_MS = 2500; +// Smoothing of the moving average once a book has enough pages measured. +const SMOOTHING = 0.1; +// After a few pages, one measurement cannot move the average by more than this factor. +const OUTLIER_FACTOR = 3; + +export type PaceSample = { book: string; page: number; active: boolean }; + +// Measures active seconds per PDF page. It is fed once per second with the same +// "active" signal as the reading goals (focus, no dialog, search or menu open). +// A page is measured only when the reader arrived on it by a small forward step +// and leaves it by another one. The first page of a session, jumps, returning +// to an earlier page and interruptions never produce a measurement. +export class PaceTracker { + private book = ''; + private page = 0; + private at = 0; + private dwell = 0; + private tracking = false; + private trusted = false; + reset() { + this.tracking = false; + this.trusted = false; + this.dwell = 0; + } + // Returns the seconds per page measured at this sample, if any. + sample(s: PaceSample, now: number): number | null { + if (!s.active || !Number.isFinite(s.page)) { + this.reset(); + return null; + } + const continuous = + this.tracking && this.book === s.book && now >= this.at && now - this.at <= MAX_GAP_MS; + let measured: number | null = null; + if (!continuous) { + this.dwell = 0; + this.trusted = false; + } else { + this.dwell += (now - this.at) / 1000; + if (s.page !== this.page) { + const step = s.page - this.page; + const forward = step >= 1 && step <= MAX_FORWARD_STEP; + if (this.trusted && forward) measured = pagePace(this.dwell, step); + this.trusted = forward; + this.dwell = 0; + } + } + this.book = s.book; + this.page = s.page; + this.at = now; + this.tracking = true; + return measured; + } +} +function pagePace(dwell: number, pages: number) { + const seconds = dwell / pages; + if (seconds < MIN_PAGE_SECONDS || seconds > IDLE_PAGE_SECONDS) return null; + return Math.min(MAX_PAGE_SECONDS, seconds); +} + +// Stored in preferences: tolerant of damaged or older data and bounded in size. +export function normalizePace(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const entries: [string, PaceEntry][] = []; + for (const [id, raw] of Object.entries(value)) { + const e = raw as Partial | null; + if (!id || id.length > MAX_ID_LENGTH || !e || typeof e !== 'object') continue; + if (typeof e.seconds !== 'number' || !Number.isFinite(e.seconds)) continue; + if (typeof e.samples !== 'number' || !Number.isFinite(e.samples) || e.samples < 1) continue; + entries.push([ + id, + { + seconds: Math.min(MAX_PAGE_SECONDS, Math.max(MIN_PAGE_SECONDS, e.seconds)), + samples: Math.min(MAX_SAMPLES, Math.floor(e.samples)), + updated: typeof e.updated === 'number' && Number.isFinite(e.updated) ? e.updated : 0, + }, + ]); + } + return Object.fromEntries( + entries.sort((a, b) => b[1].updated - a[1].updated).slice(0, MAX_BOOKS), + ); +} + +export function recordPace( + store: Record, + id: string, + seconds: number, + now: number, +): Record { + const measured = Math.min(MAX_PAGE_SECONDS, Math.max(MIN_PAGE_SECONDS, seconds)); + const old = Object.hasOwn(store, id) ? store[id] : undefined; + let entry: PaceEntry; + if (!old) entry = { seconds: measured, samples: 1, updated: now }; + else { + const bounded = + old.samples >= MIN_SAMPLES + ? Math.min(old.seconds * OUTLIER_FACTOR, Math.max(old.seconds / OUTLIER_FACTOR, measured)) + : measured; + // A plain average at first, then a moving average of roughly the last ten pages. + const weight = Math.max(1 / (old.samples + 1), SMOOTHING); + entry = { + seconds: old.seconds + (bounded - old.seconds) * weight, + samples: Math.min(MAX_SAMPLES, old.samples + 1), + updated: now, + }; + } + return normalizePace({ ...store, [id]: entry }); +} + +// Null until enough pages of this book were measured. +export function secondsPerPage(store: Record, id: string) { + const e = Object.hasOwn(store, id) ? store[id] : undefined; + return e && e.samples >= MIN_SAMPLES && e.seconds > 0 ? e.seconds : null; +} + +export type Remaining = { chapter: number | null; book: number }; +// Seconds left, counting the page being read. The chapter ends at the next +// recognized start; the last chapter has none, so only the book is returned. +export function remainingTime(input: { + page: number; + pages: number; + secondsPerPage: number | null; + chapters?: readonly number[]; + finished?: boolean; + atEnd?: boolean; +}): Remaining | null { + const { page, pages, secondsPerPage: pace } = input; + if (input.finished || input.atEnd || !pace || !Number.isFinite(pace) || pace <= 0) return null; + if (!Number.isInteger(page) || !Number.isInteger(pages) || page < 1 || page >= pages) return null; + const next = Math.min( + ...(input.chapters ?? []).filter((n) => Number.isInteger(n) && n > page && n <= pages), + ); + return { + book: (pages - page + 1) * pace, + chapter: Number.isFinite(next) ? (next - page) * pace : null, + }; +} + +export function formatDuration(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0) return ''; + const minutes = Math.max(1, Math.round(seconds / 60)); + if (minutes < 60) return `${minutes} min`; + if (minutes >= 600) return `${Math.round(minutes / 60)} h`; + const rounded = Math.round(minutes / 5) * 5; + const hours = Math.floor(rounded / 60); + const rest = rounded % 60; + return rest ? `${hours} h ${rest} min` : `${hours} h`; +} diff --git a/src/reading/page-margins.ts b/src/reading/page-margins.ts new file mode 100644 index 0000000..209d930 --- /dev/null +++ b/src/reading/page-margins.ts @@ -0,0 +1,46 @@ +export type Box = [number, number, number, number]; + +// Work on an unfiltered thumbnail, not on the screen's dark/sepia/warm colors. +// An uncertain background or a blank page keeps its original dimensions. +export function contentRows(data: Uint8ClampedArray, width: number, height: number) { + if (width < 16 || height < 16 || data.length !== width * height * 4) return null; + const samples: number[][] = [[], [], []]; + for (const y of [0, height - 1]) { + for (const x of [0, 1, width - 2, width - 1]) { + const i = (y * width + x) * 4; + for (let c = 0; c < 3; c++) samples[c].push(data[i + c]); + } + } + const paper = samples.map((values) => values.sort((a, b) => a - b)[4]); + if (samples.some((values, c) => values.some((value) => Math.abs(value - paper[c]) > 4))) + return null; + let first = height; + let last = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + if (data[i + 3] && paper.some((value, c) => Math.abs(data[i + c] - value) > 2)) { + first = Math.min(first, y); + last = y; + break; + } + } + } + return last < first ? null : { first, last: last + 1 }; +} + +export function validBox(value: unknown): value is Box { + return ( + Array.isArray(value) && + value.length === 4 && + value.every(Number.isFinite) && + value[2] > value[0] && + value[3] > value[1] + ); +} + +export function containedBox(box: Box, original: Box) { + return ( + box[0] >= original[0] && box[1] >= original[1] && box[2] <= original[2] && box[3] <= original[3] + ); +} diff --git a/src/reading/progress.ts b/src/reading/progress.ts new file mode 100644 index 0000000..649d1af --- /dev/null +++ b/src/reading/progress.ts @@ -0,0 +1,28 @@ +export type ProgressPosition = { + page: number; + pages: number; + finished?: boolean; + // The reader has scrolled to the end of the document. The library cannot know this. + atEnd?: boolean; +}; + +// The only definition of reading progress. The reader footer and the library use +// it, so a saved position shows the same percentage in both. It counts pages from +// the first (0%) to the last (100%) and ignores window size, zoom and the way +// Kindle mode joins pages, none of which change which PDF page is being read. +export function readingProgress({ + page, + pages, + finished = false, + atEnd = false, +}: ProgressPosition) { + if (finished) return 100; + if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return 0; + const current = Math.min(pages, Math.max(1, Math.round(page))); + if (current >= pages) return 100; + // Page 1 is the start even when a small document fits entirely on the screen. + if (current === 1) return 0; + if (atEnd) return 100; + // Only the first page is 0% and only the last is 100%. + return Math.min(99, Math.max(1, Math.round(((current - 1) / (pages - 1)) * 100))); +} diff --git a/src/reading/screens.ts b/src/reading/screens.ts index c87f9c3..2c683f4 100644 --- a/src/reading/screens.ts +++ b/src/reading/screens.ts @@ -8,6 +8,7 @@ export function turnScreen(input: { pixelRatio: number; direction: number; edgeReturn: EdgeReturn; + breaks?: readonly number[]; }): { top: number; edgeReturn: EdgeReturn } { const { top: from, max, height, scale, pixelRatio, direction, edgeReturn } = input; const overlap = Math.min(height * 0.2, Math.max(32, 32 * scale)); @@ -15,6 +16,16 @@ export function turnScreen(input: { // factors such as 125% or 150%. The overlap keeps a cut line on the next screen. const step = Math.max(1, Math.floor((height - overlap) * pixelRatio) / pixelRatio); let top = Math.max(0, Math.min(max, from + Math.sign(direction) * step)); + const breaks = (input.breaks || []).filter((point) => point >= 0 && point <= max + 1); + if (direction > 0) { + const next = breaks.find((point) => point > from + 1); + // Only cross after the entire preceding section has been visible. This also + // avoids a redundant screen containing just the usual overlap at its end. + if (next !== undefined && next <= from + height) top = Math.min(max, next); + } else { + const previous = breaks.filter((point) => point < from - 1).at(-1); + if (previous !== undefined) top = Math.max(top, previous); + } if ( edgeReturn && Math.abs(from - edgeReturn.to) < 1 && @@ -26,6 +37,6 @@ export function turnScreen(input: { if (Math.abs(top - from) < 0.5) return { top: from, edgeReturn }; return { top, - edgeReturn: Math.abs(top - from) < step - 1 ? { from, to: top } : null, + edgeReturn: Math.abs(Math.abs(top - from) - step) > 1 ? { from, to: top } : null, }; } diff --git a/src/reading/selection-capture.ts b/src/reading/selection-capture.ts new file mode 100644 index 0000000..1415833 --- /dev/null +++ b/src/reading/selection-capture.ts @@ -0,0 +1,115 @@ +import type { NoteRect } from '../types'; +import { MAX_NOTE_RECTS, clipBox, mergeLineRects, rectBetween, type Box } from './note-rects'; + +// What PDF.js's page view says about a page that is shown: its size in pixels, and how a point +// of it is in PDF units. `viewport` of a page view has both, with the rotation in effect. +export interface PageGeometry { + width: number; + height: number; + convertToPdfPoint(x: number, y: number): number[]; +} + +// The part of a selection that is on one page: what it says, where it is in PDF units (one +// rectangle for each line) and in the window (the same lines, in pixels, to place things near it). +export interface SelectedPiece { + page: number; + quote: string; + rects: NoteRect[]; + lines: Box[]; +} +export interface SelectionCapture { + // From the first page to the last. A selection across pages has one piece for each page. + pieces: SelectedPiece[]; +} + +// A quote is saved with the note, and the catalog accepts 20 000 bytes of it; a passage that long +// is cut before the note is refused. +const MAX_QUOTE_BYTES = 19_000; +function fitQuote(text: string) { + const bytes = new TextEncoder(); + let fitted = text; + while (bytes.encode(fitted).length > MAX_QUOTE_BYTES) fitted = fitted.slice(0, -200); + return fitted; +} + +// The part of `range` that is inside the text of one page, or null when none of it is. +function within(range: Range, layer: HTMLElement) { + const part = document.createRange(); + part.selectNodeContents(layer); + if (range.compareBoundaryPoints(Range.START_TO_START, part) > 0) + part.setStart(range.startContainer, range.startOffset); + if (range.compareBoundaryPoints(Range.END_TO_END, part) < 0) + part.setEnd(range.endContainer, range.endOffset); + return part.collapsed ? null : part; +} + +// Where the selected text is, as one rectangle for each piece of it (and no more). The rectangles +// of the range itself also count the boxes of the elements it holds, such as the end marker of +// PDF.js's text layer, which can be as large as the page while the mouse is down. +function textRects(part: Range, layer: HTMLElement) { + const rects: DOMRect[] = []; + const texts = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT); + for (let node = texts.nextNode(); node; node = texts.nextNode()) { + if (!part.intersectsNode(node)) continue; + const piece = document.createRange(); + piece.selectNodeContents(node); + if (node === part.startContainer) piece.setStart(node, part.startOffset); + if (node === part.endContainer) piece.setEnd(node, part.endOffset); + rects.push(...piece.getClientRects()); + } + return rects; +} + +// The words of a range in one line: the breaks of the text layer are spaces, and so is every other +// run of white space. (The browser's own text of a selection adds a break at each of them.) +function quoteOf(range: Range) { + const holder = document.createElement('div'); + holder.append(range.cloneContents()); + holder.querySelectorAll('br').forEach((br) => br.replaceWith(' ')); + return fitQuote((holder.textContent ?? '').replace(/\s+/g, ' ').trim()); +} + +// What the reader has selected in the pages, or null when it is nothing there: a selection that +// is collapsed, that has an end outside the pages (the side bar, the toolbar) or that has no text. +// Each page's piece is read from the text layer of that page only, so that what floats between +// the pages (the marks of the highlights) is never taken for the text. `geometry` says how a +// page is shown; a page it does not know is left out. +export function captureSelection( + content: HTMLElement, + selection: Selection | null, + geometry: (page: number) => PageGeometry | null, +): SelectionCapture | null { + if (!selection || !selection.rangeCount || selection.isCollapsed) return null; + const range = selection.getRangeAt(0); + if (!content.contains(range.startContainer) || !content.contains(range.endContainer)) return null; + const pieces: SelectedPiece[] = []; + for (const page of content.querySelectorAll('.page')) { + const number = Number(page.dataset.pageNumber); + if (!Number.isInteger(number) || number < 1 || !range.intersectsNode(page)) continue; + const layer = page.querySelector(':scope > .textLayer'); + const view = geometry(number); + const part = layer && view && within(range, layer); + if (!layer || !view || !part) continue; + const box = layer.getBoundingClientRect(); + if (!box.width || !box.height) continue; + const lines: Box[] = []; + for (const rect of textRects(part, layer)) { + const inside = clipBox(rect, box); + if (inside) lines.push(inside); + } + const merged = mergeLineRects(lines).slice(0, MAX_NOTE_RECTS); + const quote = quoteOf(part); + if (!quote || !merged.length) continue; + // The text layer is as large as the page, so its pixels are the page's: the scale only + // takes out what a zoom that is still being drawn leaves between them. + const sx = view.width / box.width, + sy = view.height / box.height; + const rects = merged.map((line) => { + const a = view.convertToPdfPoint((line.left - box.left) * sx, (line.top - box.top) * sy); + const b = view.convertToPdfPoint((line.right - box.left) * sx, (line.bottom - box.top) * sy); + return rectBetween(a[0], a[1], b[0], b[1]); + }); + pieces.push({ page: number, quote, rects, lines: merged }); + } + return pieces.length ? { pieces } : null; +} diff --git a/src/reading/selection-keys.ts b/src/reading/selection-keys.ts new file mode 100644 index 0000000..92c4a81 --- /dev/null +++ b/src/reading/selection-keys.ts @@ -0,0 +1,172 @@ +// Selecting a passage with the keyboard. Shift + ← and → grow the selection by a word, Shift + ↑ and +// ↓ by a line, as a browser does in text that can be edited. H and N (and the bar of the selection) +// act on what these keys select, so that a passage can be highlighted or noted without the mouse. + +export type SelectionKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'; +// `none`: there is no text in view to start from. `edge`: the selection could not grow (it is at the +// end of the text, or the key had nothing to do). `moved`: it grew. +export type Extension = 'moved' | 'edge' | 'none'; +export type Area = { left: number; top: number; right: number; bottom: number }; + +const STEPS: Record = { + ArrowRight: ['forward', 'word'], + ArrowLeft: ['backward', 'word'], + ArrowDown: ['forward', 'line'], + ArrowUp: ['backward', 'line'], +}; +export const isSelectionKey = (key: string): key is SelectionKey => Object.hasOwn(STEPS, key); + +// `Selection.modify` is in Chromium and in WebView2, but not in the types of the DOM. +type Modifiable = Selection & { + modify(alter: 'extend', direction: 'forward' | 'backward', granularity: 'word' | 'line'): void; +}; + +// What of `container` is on screen: the box of the scrolling area, without its scroll bars. +export function visibleArea(container: HTMLElement): Area { + const box = container.getBoundingClientRect(); + return { + left: box.left, + top: box.top, + right: box.left + container.clientWidth, + bottom: box.top + container.clientHeight, + }; +} +const meets = (box: Area, area: Area) => + box.bottom > area.top && box.top < area.bottom && box.right > area.left && box.left < area.right; + +// Whether `node` is in the text of a page: PDF.js draws it in one layer over each page, and the +// keyboard selection never leaves those layers (not for the bars, the buttons or the other pages' +// marks that are around them). +function inText(container: HTMLElement, node: Node | null) { + const layer = (node instanceof Element ? node : node?.parentElement)?.closest('.textLayer'); + return !!layer && container.contains(layer); +} + +// The first or the last line of a node's text, as a box in the window. +function edgeBox(node: Node, last: boolean): Area | null { + let found: Text | null = null; + if (node instanceof Text) found = node; + else { + const texts = document.createTreeWalker(node, NodeFilter.SHOW_TEXT); + for (let text = texts.nextNode(); text; text = texts.nextNode()) { + if (!(text as Text).data.trim()) continue; + found = text as Text; + if (!last) break; + } + } + if (!found) return null; + const range = document.createRange(); + range.selectNodeContents(found); + const boxes = [...range.getClientRects()].filter((box) => box.width || box.height); + return (last ? boxes.at(-1) : boxes[0]) ?? null; +} + +// Whether the end of the selection (its focus) comes before where it began. +function backward(selection: Selection) { + if (selection.isCollapsed || !selection.anchorNode || !selection.focusNode) return false; + const range = document.createRange(); + range.setStart(selection.anchorNode, selection.anchorOffset); + range.setEnd(selection.focusNode, selection.focusOffset); + // A range cannot end before it starts: it is collapsed instead. + return range.collapsed; +} + +// The box, in the window, of the text next to the end of the selection (where it grows), or null +// when that cannot be known. A step by word ends inside a text, and a step by line often ends between +// the lines (the element that holds them, and the number of the line). +export function selectionEnd(selection: Selection): Area | null { + const node = selection.focusNode; + if (!node) return null; + const at = selection.focusOffset; + if (node instanceof Text) { + const range = document.createRange(); + // The character next to the end: a collapsed range has no box of its own in every case. + if (at > 0) { + range.setStart(node, at - 1); + range.setEnd(node, at); + } else { + range.setStart(node, 0); + range.setEnd(node, Math.min(1, node.length)); + } + const box = range.getBoundingClientRect(); + return box.width || box.height ? box : null; + } + const back = backward(selection); + const near = [node.childNodes[at - 1], node.childNodes[at]]; + for (const child of back ? near.reverse() : near) { + const box = child && edgeBox(child, !back); + if (box) return box; + } + return null; +} + +// The first text of the pages that is in view, in the order of the document. +function firstVisibleText(container: HTMLElement) { + const area = visibleArea(container); + const range = document.createRange(); + for (const layer of container.querySelectorAll('.page > .textLayer')) { + if (!meets(layer.getBoundingClientRect(), area)) continue; + const texts = document.createTreeWalker(layer, NodeFilter.SHOW_TEXT); + for (let node = texts.nextNode(); node; node = texts.nextNode()) { + if (!(node as Text).data.trim()) continue; + range.selectNodeContents(node); + for (const box of range.getClientRects()) + if ((box.width || box.height) && meets(box, area)) return node as Text; + } + } + return null; +} + +// Whether the selection can be grown from where it is: it is in the text of the pages, and it is a +// passage, or a caret (a click leaves one) that is in view. +function growable(container: HTMLElement, selection: Selection) { + if ( + !selection.rangeCount || + !inText(container, selection.anchorNode) || + !inText(container, selection.focusNode) + ) + return false; + if (!selection.isCollapsed) return true; + const end = selectionEnd(selection); + return !!end && meets(end, visibleArea(container)); +} + +// Grows the selection of the pages one step. With nothing selected it starts at the beginning of +// the first text in view. It stays in the text layers: a step that would leave them is undone. +export function extendSelection(container: HTMLElement, key: SelectionKey): Extension { + const selection = getSelection() as Modifiable | null; + if (!selection || typeof selection.modify !== 'function') return 'edge'; + if (!growable(container, selection)) { + const start = firstVisibleText(container); + if (!start) return 'none'; + selection.collapse(start, Math.max(0, start.data.search(/\S/))); + } + const before = [ + selection.anchorNode, + selection.anchorOffset, + selection.focusNode, + selection.focusOffset, + ] as const; + // A step can end between two pages, on a layer that has no text (the one of the links): one more + // goes on to the text that follows. + for (let tries = 0; tries < 4; tries++) { + const [node, offset] = [selection.focusNode, selection.focusOffset]; + selection.modify('extend', ...STEPS[key]); + if ( + inText(container, selection.focusNode) || + (selection.focusNode === node && selection.focusOffset === offset) + ) + break; + } + if (!inText(container, selection.anchorNode) || !inText(container, selection.focusNode)) { + if (before[0] && before[2]) + selection.setBaseAndExtent(before[0], before[1], before[2], before[3]); + return 'edge'; + } + return selection.anchorNode === before[0] && + selection.anchorOffset === before[1] && + selection.focusNode === before[2] && + selection.focusOffset === before[3] + ? 'edge' + : 'moved'; +} diff --git a/src/reading/sidebar.ts b/src/reading/sidebar.ts new file mode 100644 index 0000000..36f0690 --- /dev/null +++ b/src/reading/sidebar.ts @@ -0,0 +1,64 @@ +import type { SidebarTab } from '../types'; + +// The tabs of the reader's side bar, in order. +export const SIDEBAR_TABS: readonly SidebarTab[] = ['outline', 'pages', 'bookmarks', 'notes']; + +// The width is in CSS pixels. The window caps it, so that the PDF always keeps most +// of the screen: at the smallest window (800 px) the bar is at most 320 px wide. +export const SIDEBAR_WIDTH = Object.freeze({ + min: 220, + max: 480, + fallback: 260, + step: 16, + share: 0.4, +}); + +// The widest the bar can be in a window of this width. +export function sidebarMax(windowWidth: number): number { + if (!Number.isFinite(windowWidth) || windowWidth <= 0) return SIDEBAR_WIDTH.max; + return Math.max( + SIDEBAR_WIDTH.min, + Math.min(SIDEBAR_WIDTH.max, Math.floor(windowWidth * SIDEBAR_WIDTH.share)), + ); +} + +// Without a window width, only the fixed limits apply (what is saved in the preferences). +export function clampSidebarWidth(width: number, windowWidth?: number): number { + const max = windowWidth === undefined ? SIDEBAR_WIDTH.max : sidebarMax(windowWidth); + const value = Number.isFinite(width) ? Math.round(width) : SIDEBAR_WIDTH.fallback; + return Math.min(max, Math.max(SIDEBAR_WIDTH.min, value)); +} + +// The width after a key on the drag handle, or null when the key does nothing. +// The bar is on the left, so the right arrow widens it. +export function widthAfterKey( + width: number, + key: string, + windowWidth: number, + large = false, +): number | null { + const step = SIDEBAR_WIDTH.step * (large ? 3 : 1); + switch (key) { + case 'ArrowRight': + return clampSidebarWidth(width + step, windowWidth); + case 'ArrowLeft': + return clampSidebarWidth(width - step, windowWidth); + case 'Home': + return SIDEBAR_WIDTH.min; + case 'End': + return sidebarMax(windowWidth); + default: + return null; + } +} + +// The tab that an arrow key moves to; the tabs wrap around. Null for other keys. +export function tabAfterKey(tab: SidebarTab, key: string): SidebarTab | null { + const at = Math.max(0, SIDEBAR_TABS.indexOf(tab)), + last = SIDEBAR_TABS.length - 1; + if (key === 'ArrowRight') return SIDEBAR_TABS[at === last ? 0 : at + 1]; + if (key === 'ArrowLeft') return SIDEBAR_TABS[at === 0 ? last : at - 1]; + if (key === 'Home') return SIDEBAR_TABS[0]; + if (key === 'End') return SIDEBAR_TABS[last]; + return null; +} diff --git a/src/reading/spread.ts b/src/reading/spread.ts new file mode 100644 index 0000000..d914506 --- /dev/null +++ b/src/reading/spread.ts @@ -0,0 +1,47 @@ +import type { Spread } from '../types'; + +export const SPREADS: readonly Spread[] = ['none', 'odd', 'even']; + +// PDF.js SpreadMode: NONE = 0, ODD = 1, EVEN = 2. ODD starts a pair on every odd +// page (1-2, 3-4, 5-6). EVEN leaves page 1 alone, as a cover, and starts the +// pairs on even pages (2-3, 4-5). The names follow PDF.js, not the page counts. +export const PDFJS_SPREAD: Readonly> = { none: 0, odd: 1, even: 2 }; + +// The pages laid out in the same row as `page`: the page alone, or its pair. +// This mirrors how PDF.js groups pages, so navigation and goals agree with it. +export function spreadPages(page: number, pages: number, spread: Spread): number[] { + if (!Number.isFinite(page) || !Number.isFinite(pages) || pages < 1) return []; + const n = Math.min(pages, Math.max(1, Math.round(page))); + if (spread === 'none' || (spread === 'even' && n === 1)) return [n]; + const first = n % 2 === (spread === 'odd' ? 1 : 0) ? n : n - 1; + return first + 1 <= pages ? [first, first + 1] : [first]; +} + +export type Rect = { left: number; right: number; top: number; bottom: number }; + +// A neighbor shares the screen with the current page when at least half of its +// width and a quarter of its height (or of the window, if shorter) are in view. +export function sharesScreen(page: Rect, view: Rect) { + const width = page.right - page.left, + height = page.bottom - page.top; + if (!(width > 0) || !(height > 0)) return false; + const across = Math.min(page.right, view.right) - Math.max(page.left, view.left), + down = Math.min(page.bottom, view.bottom) - Math.max(page.top, view.top); + return across >= width / 2 && down >= Math.min(height, view.bottom - view.top) / 4; +} + +// The pages on screen together. The current page always counts; the other page of +// its pair counts when `visible` says it is on screen too. `first` is a stable +// identity for the screen: the same value whichever page PDF.js calls current. +export function screenPages( + page: number, + pages: number, + spread: Spread, + visible: (page: number) => boolean, +) { + const pair = spreadPages(page, pages, spread); + const seen = pair.filter((n) => n === page || visible(n)); + return seen.length + ? { first: seen[0], last: seen[seen.length - 1] } + : { first: page, last: page }; +} diff --git a/src/reading/thumbnails.ts b/src/reading/thumbnails.ts new file mode 100644 index 0000000..4198eb4 --- /dev/null +++ b/src/reading/thumbnails.ts @@ -0,0 +1,56 @@ +// Thumbnails are small and drawn only near the visible part of the list. The limits +// keep the memory of a long book bounded: at most `maxLive` canvases exist at once. +export const THUMBNAIL = Object.freeze({ + // CSS width of a thumbnail. The canvas has up to `density` times as many pixels. + width: 130, + density: 2, + // Pixels above and below the list in which thumbnails are already prepared. + margin: 200, + // The ceiling of live canvases. Those in or near the view are never released. + maxLive: 16, +}); + +// The width, in canvas pixels, for a screen with this pixel density. +export function thumbnailPixels(cssWidth: number, density: number): number { + const scale = Number.isFinite(density) ? Math.min(THUMBNAIL.density, Math.max(1, density)) : 1; + return Math.max(1, Math.round(cssWidth * scale)); +} + +// Which drawn thumbnails to release. `live` goes from the least to the most recently +// used; `keep` are the pages in or near the view. The oldest others go first, until +// no more than `max` are left. If everything left is in view, nothing more is released. +export function chooseEvictions( + live: readonly number[], + keep: ReadonlySet, + max: number, +): number[] { + const out: number[] = []; + let count = live.length; + for (const page of live) { + if (count <= max) break; + if (keep.has(page)) continue; + out.push(page); + count--; + } + return out; +} + +// The pending page closest to `anchor`; the lower one when two are equally close. +export function nearestPage(pending: Iterable, anchor: number): number | null { + let best: number | null = null; + for (const page of pending) { + if ( + best === null || + Math.abs(page - anchor) < Math.abs(best - anchor) || + (Math.abs(page - anchor) === Math.abs(best - anchor) && page < best) + ) + best = page; + } + return best; +} + +// The middle of the pages in view, where the next thumbnail is most useful. +export function anchorPage(near: Iterable): number { + const pages = [...near].sort((a, b) => a - b); + return pages.length ? pages[Math.floor(pages.length / 2)] : 1; +} diff --git a/src/types.ts b/src/types.ts index 1c7b959..04ad675 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,26 @@ +// The colours of a highlight, by the name that is saved (the same four as src-tauri/src/notes.rs). +export type NoteColor = 'yellow' | 'green' | 'blue' | 'pink'; +// A rectangle of a highlight in PDF units, [x1, y1, x2, y2] with x1 <= x2 and y1 <= y2. +// It does not depend on the zoom or the rotation, and it is one line of text. +export type NoteRect = [number, number, number, number]; export interface Note { id: string; page: number; text: string; quote: string; created: number; + // A highlight is a note with `rects`; its text can be empty. Notes from before have neither. + rects?: NoteRect[]; + color?: NoteColor; } export interface Book { + goalHistory?: Record; + readingGoal?: BookGoal; + readingDays?: Record; id: string; title: string; + // From the PDF's own metadata, read in the background after the import. Empty when it has none. + author: string; originalName: string; hash: string; size: number; @@ -24,21 +37,96 @@ export interface Book { bookmarks: number[]; notes: Note[]; cover: boolean; + // Free labels of the reader, at most 20 (see library/tags.ts). + tags: string[]; +} +// What stays of a document that was deleted for good: no file, only what the reading history +// needs. It is never listed in the library or in Removidos. +export interface ArchivedBook { + id: string; + title: string; + author: string; + readingDays: Record; + goalHistory: Record; +} +// What `purge_books` did: the documents that left the catalog, the bytes of PDF copies deleted, +// and one line for each file that could not be deleted. +export interface PurgeResult { + purged: number; + freed: number; + failed: string[]; +} +// A document an import found already in the library. +export interface ExistingBook { + id: string; + title: string; +} +// What `import_pdfs` says: the books that came in (or came back from Removidos), those that +// were already there (`duplicates` counts them), and one line for each file that failed. +export interface ImportResult { + added: Book[]; + duplicates: number; + existing: ExistingBook[]; + errors: string[]; } export interface Snapshot { books: Book[]; trash: Book[]; + archived: ArchivedBook[]; folder: string; warning: string | null; } export type ReadingMode = 'scroll' | 'kindle'; +// The sections of the reader's side bar, in the order of its tabs. +export type SidebarTab = 'outline' | 'pages' | 'bookmarks' | 'notes'; +// Pages side by side while scrolling: none, pairs starting on odd pages (1-2, 3-4), +// or the cover alone and then pairs starting on even pages (2-3, 4-5). +export type Spread = 'none' | 'odd' | 'even'; +// Moving average of the active reading time per PDF page, kept for one book. +export interface PaceEntry { + seconds: number; + samples: number; + updated: number; +} export interface Preferences { + pace: Record; + goalHistory: Record; + dailyGoal: DailyGoal | null; theme: 'light' | 'dark' | 'system'; paper: 'light' | 'dark' | 'sepia'; warm: number; brightness: number; readingMode: ReadingMode; + spread: Spread; + sidebarOpen: boolean; + sidebarTab: SidebarTab; + sidebarWidth: number; + joinPages: boolean; + chapterBreaks: boolean; resume: boolean; + // The keys of one letter (K, L, S, D, R, H, N) act in the reader. Off, only the shortcuts with + // Ctrl or Alt, the arrows and the like do (WCAG 2.1.4). + singleKeys: boolean; view: 'grid' | 'list'; sort: string; + // The tips already shown, by name (see hints.ts). Each is shown only once. + hints: string[]; +} +export type GoalUnit = 'pages' | 'chapters'; +export interface DailyGoal { + unit: GoalUnit; + target: number; +} +// A document has a goal of its own (`custom`) or none of its own. `inherit` and `none` both mean +// "no goal of its own": the document only counts in the general goal. New saves write `inherit`; +// `none` is what older catalogs may hold, and all three stay valid in the catalog (the native side +// accepts them). +export interface BookGoal extends DailyGoal { + mode: 'inherit' | 'custom' | 'none'; +} +export interface ReadingDay { + pages: number[]; + chapters: number[]; + pageAdjustment: number; + chapterAdjustment: number; } diff --git a/src/ui.css b/src/ui.css index 7ab1b16..b6729cb 100644 --- a/src/ui.css +++ b/src/ui.css @@ -9,8 +9,9 @@ --panel: #fffefa; --sidebar: #efeee8; --text: #252f2c; - --muted: #737972; + --muted: #5f6861; --line: #e2e4dc; + --field-line: #868d84; --accent: #3f6554; --accent-hover: #315442; --soft: #e6ede5; @@ -18,6 +19,46 @@ --reader: #e5e7e3; --surface: #ffffff; --danger: #a0443d; + /* The day of a goal that was missed. */ + --missed: #ad8251; + /* The type scale: a few named sizes, and each role takes one. Nothing that informs is below + --fs-label (11px): --fs-micro is for key caps and the like. The supporting text of a dialog + and its captions are --fs-body (12px) or more. */ + --fs-micro: 10px; + --fs-label: 11px; + --fs-small: 11px; + --fs-body: 12px; + --fs-strong: 13px; + --fs-subtitle: 18px; + --fs-title: 21px; + --fs-display: 26px; + /* Motion: the quick one for a control that answers, the other for what moves into place. */ + --motion-fast: 0.12s; + --motion-base: 0.18s; + --ease-out: ease-out; + /* What a control that cannot be used looks like, whatever it is. */ + --disabled-opacity: 0.38; + /* The height of a cover in the grid: the buttons of a card hang from it. */ + --cover-h: 220px; + /* The frame of a cover: five quiet tones, one for each hue of a card. The page or the image in + the middle is light in any theme; only the frame follows the theme. */ + --cover-0: #ebece6; + --cover-1: #e8e7e0; + --cover-2: #e5e9e8; + --cover-3: #e8e2dc; + --cover-4: #e2e7de; + /* The surface around the page for each paper of the reader (the same in both themes). */ + --paper-sepia: #d9cfbd; + --paper-dark: #171b1b; + /* The sepia paper itself: the filter that the page of the reader (and its thumbnail) goes through, + and the colour that a white page comes out with, which the sample in the preferences shows. They + go together: white through the filter is the colour, so change one and work out the other. */ + --paper-sepia-filter: brightness(0.85) sepia(0.39) brightness(0.98); + --paper-sepia-page: #f1e5cf; + /* The height of the top of the library, which stays in view while the list scrolls. */ + --library-top-h: 87px; + /* The room at the sides of the bars of the reader, so that their edges line up. */ + --reader-inset: 20px; } :root[data-theme='dark'] { color-scheme: dark; @@ -25,8 +66,9 @@ --panel: #222925; --sidebar: #1d231f; --text: #e3e9e2; - --muted: #a2afa4; + --muted: #aab6ac; --line: #354038; + --field-line: #6b7a6f; --accent: #b5cfb1; --accent-hover: #c8dec3; --soft: #334637; @@ -34,6 +76,12 @@ --reader: #151a17; --surface: #29312b; --danger: #efa39a; + --missed: #c9a06c; + --cover-0: #2a332e; + --cover-1: #2d332d; + --cover-2: #28332f; + --cover-3: #302f2b; + --cover-4: #29312a; color: var(--text); background: var(--bg); } @@ -61,12 +109,12 @@ button { border: 0; border-radius: 7px; transition: - background 0.12s, - color 0.12s; + background var(--motion-fast) var(--ease-out), + color var(--motion-fast) var(--ease-out); white-space: nowrap; } button:disabled { - opacity: 0.38; + opacity: var(--disabled-opacity); cursor: default; } button:hover:not(:disabled) { @@ -82,12 +130,21 @@ textarea:focus-visible { button svg { flex-shrink: 0; } +/* The line of an icon is as thick at one size as at another: it is measured on the screen, not in + the 24-unit drawing. The big drawings of the empty states and of the drop have a heavier one. */ +svg.icon :is(path, circle, rect) { + vector-effect: non-scaling-stroke; +} +.empty-illustration svg, +#drop-overlay svg { + stroke-width: 2; +} input, select, textarea { color: var(--text); background: var(--panel); - border: 1px solid var(--line); + border: 1px solid var(--field-line); border-radius: 7px; } input { @@ -120,13 +177,13 @@ p { margin: 0; } h1 { - font-size: 32px; + font-size: var(--fs-display); font-weight: 600; - letter-spacing: -1px; + letter-spacing: -0.6px; line-height: 1.2; } h2 { - font-size: 23px; + font-size: var(--fs-title); font-weight: 600; letter-spacing: -0.5px; } @@ -135,7 +192,7 @@ h3 { } kbd { font: - 10px 'Segoe UI', + var(--fs-label) 'Segoe UI', sans-serif; color: var(--muted); padding: 3px 5px; @@ -148,7 +205,7 @@ kbd { color: var(--panel); padding: 12px 18px; font-weight: 600; - font-size: 13px; + font-size: var(--fs-body); border: 1px solid transparent; } .primary:hover:not(:disabled) { @@ -162,7 +219,7 @@ kbd { background: var(--panel); padding: 10px 14px; font-weight: 600; - font-size: 13px; + font-size: var(--fs-body); } .icon-button { width: 34px; @@ -185,37 +242,27 @@ kbd { .text-button { padding: 7px 4px; color: var(--accent); - font-size: 12px; + font-size: var(--fs-body); font-weight: 600; } .text-button svg { width: 15px; height: 15px; } -.eyebrow { - font-size: 10px; - font-weight: 600; - letter-spacing: 1.6px; - color: var(--muted); -} .quiet-label { - font-size: 12px; + font-size: var(--fs-body); color: var(--muted); } -.status-dot { - width: 6px; - height: 6px; - display: inline-block; - background: #728d70; - border-radius: 50%; - margin-right: 5px; -} .library-shell { height: 100vh; display: grid; grid-template-columns: 228px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; } .library-sidebar { + grid-row: 1 / span 2; + min-height: 0; + overflow-y: auto; background: var(--sidebar); padding: 30px 16px 19px; border-right: 1px solid var(--line); @@ -244,32 +291,69 @@ kbd { width: 23px; height: 23px; } -.brand > div > span { - display: block; - font-size: 8px; - font-weight: 500; - letter-spacing: 2.5px; - color: var(--muted); - margin-top: 1px; -} .add-button { + position: relative; + overflow: hidden; padding: 11px 12px; gap: 7px; - font-size: 12px; + font-size: var(--fs-body); justify-content: flex-start; } .add-button svg { width: 17px; } +.add-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* While the PDFs are copied the button says how far it is, and stays on, not dimmed like a + button that does nothing. The bar is as wide as the part that is done. */ +.add-button[aria-busy='true']:disabled { + opacity: 1; + cursor: progress; +} +.add-button[aria-busy='true'] > svg, +.add-button[aria-busy='true'] > kbd { + display: none; +} +.add-bar { + position: absolute; + inset: auto 0 0; + height: 3px; + background: color-mix(in srgb, currentColor 22%, transparent); +} +.add-bar i { + display: block; + height: 100%; + background: currentColor; + transform-origin: left; + transform: scaleX(var(--import-progress, 0)); + transition: transform var(--motion-base) var(--ease-out); +} +/* A quiet second way in, under the button: a whole folder, with its subfolders. */ +.add-folder { + align-self: flex-start; + margin: 4px 0 0 6px; + padding: 5px 8px; + gap: 6px; + font-size: var(--fs-small); + font-weight: 500; +} +.add-folder svg { + width: 14px; + height: 14px; +} .add-button kbd { margin-left: auto; - font-size: 9px; + font-size: var(--fs-micro); background: #fff1; color: inherit; border-color: #fff3; } .nav-label { - font-size: 9px; + font-size: var(--fs-label); letter-spacing: 1.4px; font-weight: 600; color: var(--muted); @@ -281,7 +365,7 @@ kbd { text-align: left; padding: 12px 14px; margin: 3px 0; - font-size: 12px; + font-size: var(--fs-body); gap: 11px; color: var(--muted); } @@ -293,7 +377,7 @@ kbd { flex: 1; } .nav-item small { - font-size: 10px; + font-size: var(--fs-label); font-weight: 600; } .nav-item.active { @@ -304,44 +388,45 @@ kbd { .sidebar-bottom { margin-top: auto; } +/* A line sets the shortcuts of the app apart from the library, whatever the height of the window. */ +.sidebar-bottom::before { + content: ''; + display: block; + height: 1px; + margin: 16px 14px 10px; + background: var(--line); +} .sidebar-bottom .nav-item { padding-top: 9px; padding-bottom: 9px; } -.local-note { - margin: 20px 14px 22px; - padding: 16px 0 18px; - border-bottom: 1px solid var(--line); - font-size: 11px; - color: var(--text); -} -.local-note > span:last-child { - display: block; - font-size: 10px; - color: var(--muted); - margin: 7px 0 0 11px; -} .library-main { + grid-column: 2; + grid-row: 1; overflow: auto; scrollbar-gutter: stable; + /* A card that the focus brings into view is not left under the top. */ + scroll-padding-top: var(--library-top-h); } +/* The search and the theme stay at the top while the list scrolls under them. */ .library-top { - height: 87px; + position: sticky; + top: 0; + z-index: 10; + height: var(--library-top-h); padding: 0 42px; display: flex; align-items: center; gap: 18px; + background: var(--bg); border-bottom: 1px solid var(--line); } -.library-top > .eyebrow { - margin-right: auto; -} .search-input { display: flex; align-items: center; gap: 9px; background: var(--panel); - border: 1px solid var(--line); + border: 1px solid var(--field-line); border-radius: 7px; padding: 0 12px; color: var(--muted); @@ -355,135 +440,161 @@ kbd { flex: 1; height: 100%; width: 100%; - font-size: 12px; + font-size: var(--fs-body); } +/* The ring of any other control (2 px in the accent), drawn around the box and not on the field. */ .search-input:focus-within { border-color: var(--accent); - box-shadow: 0 0 0 2px var(--soft); + outline: 2px solid var(--accent); + outline-offset: 2px; } .search-input svg { width: 16px; height: 16px; } +/* The search takes the room of the top; the theme stays at the end. */ .library-top .search-input { - width: 280px; + flex: 1; + max-width: 560px; +} +.library-top #theme-toggle { + margin-left: auto; } .search-input kbd { white-space: nowrap; } .library-content { - padding: 38px 42px 0; + padding: 30px 42px 32px; max-width: 1600px; margin: auto; } +/* The title with the goal of the day at the end of the same line. */ .heading-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; - margin-bottom: 28px; + margin-bottom: 20px; +} +.heading-text { + flex: 1; + min-width: 0; +} +.heading-text h1 { + overflow-wrap: anywhere; } .heading-row p { color: var(--muted); - font-size: 13px; - margin-top: 10px; -} -.heading-row > .quiet-label { - align-self: flex-end; - margin-bottom: 4px; + font-size: var(--fs-strong); + margin-top: 6px; } +/* "Continuar": one line, as tall as a row of the list. The cover in miniature, the title with where the + reading is, the gauge of the document, and the way back in. The whole strip opens the document. */ #continue { display: flex; align-items: center; - gap: 25px; + gap: 16px; background: var(--soft); border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); border-radius: 10px; - min-height: 132px; - padding: 22px 28px; - overflow: hidden; - margin-bottom: 32px; + padding: 10px 16px; + margin-bottom: 20px; + cursor: pointer; + transition: border-color var(--motion-fast) var(--ease-out); } -.continue-art { - position: relative; - width: 86px; - height: 86px; +#continue:hover { + border-color: color-mix(in srgb, var(--accent) 45%, transparent); +} +#continue .book-cover { + width: 44px; + height: 60px; + padding: 0; flex-shrink: 0; - display: grid; - place-items: center; - color: var(--accent); - background: color-mix(in srgb, var(--panel) 30%, transparent); - border-radius: 50%; + border-radius: 4px; } -.continue-art > svg { - width: 37px; - height: 37px; - z-index: 1; - transform: rotate(-7deg); +#continue .paper-preview { + padding: 8px 5px; } -.orbit { - position: absolute; - border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent); - border-radius: 50%; - width: 110px; - height: 110px; - transform: rotate(25deg) scaleY(0.6); +#continue .paper-preview > span { + font-size: 5px; + margin-bottom: 6px; +} +#continue .paper-preview > svg { + display: none; } -.orbit-two { - transform: rotate(-40deg) scaleY(0.65); - width: 115px; +#continue .paper-lines { + height: 15px; + background-size: 100% 7px; } .continue-info { flex: 1; min-width: 0; } .continue-info h2 { - font-size: 21px; - margin: 9px 0 8px; + font-size: var(--fs-strong); + letter-spacing: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.continue-info > span:last-child { - font-size: 11px; +.continue-meta { + display: block; + margin-top: 4px; + font-size: var(--fs-small); color: var(--muted); } -.continue-info > .eyebrow { - font-size: 8px; - color: var(--accent); - letter-spacing: 1.3px; +/* Under the title and the page, so the strip has no gap in the middle. */ +.continue-bar { + width: 100%; + max-width: 320px; + height: 4px; + margin-top: 8px; + background: color-mix(in srgb, var(--accent) 22%, transparent); + border-radius: 4px; + overflow: hidden; } -.dot-separator { - padding: 0 8px; +.continue-bar i { + display: block; + height: 100%; + background: var(--accent); + border-radius: 4px; } #continue > .primary { - font-size: 12px; flex-shrink: 0; + padding: 9px 16px; } .collection-toolbar { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 14px; + gap: 10px 14px; margin: 0 0 21px; } -.collection-toolbar > span { - font-size: 9px; - font-weight: 600; - letter-spacing: 1.3px; - color: var(--muted); -} +/* The count of the list at the left; the order and the view at the right, even when there is no count. */ .collection-toolbar > div { display: flex; align-items: center; gap: 16px; + margin-left: auto; } -.collection-toolbar select { - border: 0; - background: none; - font-size: 11px; +/* The order of the list: a name that is seen, and a field like the others (the line of the fields), + as tall as the group of the view next to it. */ +.sort-field { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--fs-small); color: var(--muted); } +.sort-field label { + white-space: nowrap; +} +.collection-toolbar select { + height: 35px; + padding: 0 28px 0 10px; + font-size: var(--fs-small); +} .segmented { display: flex; background: var(--sidebar); @@ -526,36 +637,36 @@ kbd { .book-open:hover:not(:disabled) { background: none; } -.book-open:hover .book-cover { +button.book-open:hover .book-cover { border-color: color-mix(in srgb, var(--accent) 50%, transparent); box-shadow: 0 4px 14px #263d2910; } .book-cover { - height: 220px; + height: var(--cover-h); border-radius: 8px; - background: #ebece6; + background: var(--cover-0); display: flex; align-items: center; justify-content: center; - border: 1px solid #e2e3da; + border: 1px solid var(--line); overflow: hidden; padding: 21px 25px; transition: - border-color 0.15s, - box-shadow 0.15s; + border-color var(--motion-fast) var(--ease-out), + box-shadow var(--motion-fast) var(--ease-out); position: relative; } .book-cover[data-hue='1'] { - background: #e8e7e0; + background: var(--cover-1); } .book-cover[data-hue='2'] { - background: #e5e9e8; + background: var(--cover-2); } .book-cover[data-hue='3'] { - background: #e8e2dc; + background: var(--cover-3); } .book-cover[data-hue='4'] { - background: #e2e7de; + background: var(--cover-4); } .book-cover img { object-fit: contain; @@ -576,7 +687,7 @@ kbd { } .paper-preview > span { display: block; - font-size: 9px; + font-size: var(--fs-micro); font-weight: 600; letter-spacing: 2px; margin-bottom: 13px; @@ -606,23 +717,40 @@ kbd { padding: 13px 1px 9px; } .book-info h3 { - font-size: 12px; + font-size: var(--fs-body); line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 56px; } +/* In the grid the title is what tells one card from another: it gets two lines before the dots. + The list has the whole width of the row, and one line is enough there. */ +.book-grid .book-info h3 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; + overflow-wrap: anywhere; +} .book-info p { - font-size: 10px; + font-size: var(--fs-label); color: var(--muted); margin: 5px 0 13px; } .book-info p span { padding: 0 4px; } +/* The author, under the title. The room on the right is the one the card's buttons take. */ +.book-info .book-author { + margin: 1px 0 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 56px; +} .book-state { - font-size: 9px; + font-size: var(--fs-label); color: var(--muted); height: 25px; } @@ -639,9 +767,6 @@ kbd { display: block; border-radius: 4px; } -.new-badge { - font-size: 9px; -} .finished-badge { display: inline-flex; align-items: center; @@ -655,7 +780,7 @@ kbd { .card-actions { position: absolute; right: -3px; - top: 226px; + top: calc(var(--cover-h) + 6px); display: flex; gap: 0; } @@ -672,6 +797,11 @@ kbd { width: 15px; height: 15px; } +/* The card clips what is drawn outside it, and its buttons are where the arrows leave the focus: the + ring is drawn inside them. */ +.book-card :is(.book-open, .card-actions button):focus-visible { + outline-offset: -3px; +} .book-list { display: flex; flex-direction: column; @@ -679,8 +809,18 @@ kbd { } .book-list .book-card { border-bottom: 1px solid var(--line); - padding: 12px 0; - contain-intrinsic-size: auto 100px; + padding: 8px 0; + contain-intrinsic-size: auto 90px; + transition: background var(--motion-base) var(--ease-out); +} +/* The card that "Mostrar" took the reader to, for a moment. The ring is inside the cover: the + card clips what is drawn outside it. */ +.book-card.revealed .book-cover { + border-color: var(--accent); + box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); +} +.book-list .book-card.revealed { + background: var(--soft); } .book-list .book-open { display: flex; @@ -689,9 +829,9 @@ kbd { padding-right: 80px; } .book-list .book-cover { - height: 78px; - width: 64px; - padding: 7px 10px; + height: 72px; + width: 58px; + padding: 6px 7px; flex-shrink: 0; border-radius: 4px; } @@ -709,17 +849,24 @@ kbd { height: 15px; background-size: 100% 7px; } +/* The height of a row is the one of its text: the room above and below it is the card's own. */ .book-list .book-info { flex: 1; min-width: 0; + padding: 0 1px; } .book-list .book-info h3 { - font-size: 14px; + font-size: var(--fs-strong); padding: 0; } .book-list .book-info p { margin-bottom: 6px; } +.book-list .book-info .book-author { + margin: 0; + padding: 0; + font-size: var(--fs-small); +} .book-list .progress-track { max-width: 160px; } @@ -731,29 +878,6 @@ kbd { top: 50%; transform: translateY(-50%); } -.library-footer { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - margin-top: 32px; - padding: 20px 0; - border-top: 1px solid var(--line); - color: var(--muted); - font-size: 10px; -} -.library-footer > span { - display: flex; - align-items: center; - gap: 7px; -} -.library-footer svg { - width: 13px; - height: 13px; -} -.library-footer .text-button { - font-size: 10px; -} .empty-state { min-height: 350px; display: flex; @@ -780,935 +904,1975 @@ kbd { height: 38px; transform: rotate(6deg); } -.empty-state > .eyebrow { - font-size: 8px; - letter-spacing: 1.4px; -} .empty-state h2 { - font-size: 25px; - margin: 12px 0; + font-size: var(--fs-title); + margin: 0 0 12px; } .empty-state p { - font-size: 12px; + font-size: var(--fs-body); line-height: 1.9; color: var(--muted); } -.empty-state > .primary { +.empty-state > .primary, +.empty-state > .secondary { margin-top: 25px; } -.empty-state > small { - font-size: 10px; - color: var(--muted); - margin-top: 12px; +.empty-state > .text-button { + margin-top: 10px; } -#reader { - height: 100vh; - display: flex; - flex-direction: column; - background: var(--reader); +/* Tags in the side bar: a list that scrolls by itself, so that the buttons under it stay in reach. */ +.tag-section .nav-label { + padding-top: 24px; } -.reader-header { - height: 57px; - flex-shrink: 0; - display: flex; - align-items: center; - gap: 12px; - padding: 0 17px; - background: var(--panel); - border-bottom: 1px solid var(--line); +#tag-nav { + max-height: 232px; + overflow-y: auto; + overscroll-behavior: contain; } -.header-divider { - height: 22px; - width: 1px; - background: var(--line); - flex-shrink: 0; - margin: 0 4px; +.tag-item { + padding-top: 8px; + padding-bottom: 8px; } -.reader-document { +.tag-item span { min-width: 0; - flex: 1; -} -.reader-document strong { - display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 12px; - font-weight: 600; } -.reader-document > span { - display: block; - font-size: 9px; - color: var(--muted); - margin-top: 4px; +/* The scrolling list would cut the outline of the focus, so it is drawn inside the button. */ +.tag-item:focus-visible { + outline-offset: -2px; } -.reader-header > .text-button { - font-size: 10px; +/* A card is ticked with Ctrl or Shift + click, or with its box. The box is always there, dimmed, so + that the reader finds it; it is full when the pointer or the focus is on the card, and on every + card as soon as one is ticked. */ +.book-card { + user-select: none; } -.reader-toolbar { - display: flex; - align-items: center; - justify-content: space-between; +.select-check { + appearance: none; + position: absolute; + top: 8px; + left: 8px; + z-index: 2; + width: 24px; + height: 24px; + margin: 0; + padding: 0; + border: 1.5px solid var(--muted); + border-radius: 6px; background: var(--panel); - padding: 8px 20px; - gap: 15px; - flex-shrink: 0; - border-bottom: 1px solid var(--line); + box-shadow: 0 1px 4px #0003; + opacity: 0.6; + cursor: pointer; + transition: opacity var(--motion-fast) var(--ease-out); } -.toolbar-group { - display: flex; - align-items: center; - gap: 5px; - min-width: 0; +.select-check::after { + content: ''; + position: absolute; + left: 7px; + top: 3px; + width: 5px; + height: 10px; + border: solid var(--panel); + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + transition: transform var(--motion-fast) var(--ease-out); } -.toolbar-group .icon-button { - width: 32px; - height: 32px; +.select-check:checked { + background: var(--accent); + border-color: var(--accent); } -.toolbar-group .icon-button svg { - width: 17px; - height: 17px; +.select-check:checked::after { + transform: rotate(45deg) scale(1); } -#page-number { - width: 49px; - height: 28px; - font-size: 12px; - text-align: center; - padding: 0 3px; - appearance: textfield; - -moz-appearance: textfield; +.book-card:hover .select-check, +.book-card:focus-within .select-check, +.selecting .select-check, +.select-check:checked, +.select-check:focus-visible { + opacity: 1; } -#page-number::-webkit-inner-spin-button { - display: none; +.book-card.selected .book-cover { + border-color: var(--accent); + box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); } -#page-total { - font-size: 11px; - color: var(--muted); - white-space: nowrap; - padding: 0 5px; +.book-list .book-card { + padding-left: 34px; } -#zoom { - font-size: 11px; - max-width: 155px; - height: 29px; - padding: 3px 8px; - border-color: transparent; - background: transparent; +.book-list .book-card.selected { + background: var(--soft); } -.reader-body { - position: relative; - min-height: 0; - flex: 1; - display: flex; +.book-list .select-check { + top: 50%; + left: 6px; + margin-top: -12px; } -#viewer-wrap { - position: relative; - flex: 1; - min-width: 0; - overflow: hidden; +.book-list .book-card:has([data-restore]) .book-open { + padding-right: 120px; } -#viewer-container { - position: absolute; - inset: 0; - overflow: auto; - outline: none; - scrollbar-gutter: stable both-edges; - overscroll-behavior: contain; -} -#pdf-viewer { - padding: 24px 20px 32px; - min-height: 100%; +/* A card of Removidos has a wider pair of buttons: in the grid they go under the card, and the + title keeps its whole width. */ +.book-grid .book-card:has([data-restore]) .card-actions { + position: static; + align-items: center; + justify-content: space-between; + margin-top: 2px; } -.pdfViewer .page { - margin: 0 auto 20px !important; - border: 0 !important; - box-shadow: 0 2px 9px #0002; - overflow: visible !important; +.book-grid .book-card:has([data-restore]) .book-info h3, +.book-grid .book-card:has([data-restore]) .book-info .book-author, +.book-grid .book-card:has([data-restore]) .book-info .book-tags { + padding-right: 0; } -.pdfViewer .canvasWrapper { +/* The tags of a card, and where a search found it: one discreet line each. */ +.book-info .book-tags, +.book-info .book-found { + margin: 2px 0 0; overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent); } -.pdfViewer .textLayer { - opacity: 1; -} -.pdfViewer .textLayer ::selection { - background: rgb(75 137 106 / 0.28); -} -:root[data-paper='dark'] #viewer-wrap { - background: #171b1b; -} -:root[data-paper='dark'] .pdfViewer .canvasWrapper { - filter: invert(0.91) hue-rotate(180deg); +.book-info .book-tags { + padding-right: 56px; } -:root[data-paper='sepia'] #viewer-wrap { - background: #d9cfbd; +.book-info .book-found { + display: flex; + align-items: center; + gap: 4px; } -:root[data-paper='sepia'] .pdfViewer .canvasWrapper { - filter: sepia(0.45) brightness(0.97); +.book-found svg { + width: 11px; + height: 11px; } -#warm-overlay { - pointer-events: none; - position: absolute; - inset: 0; - background: #ff9a35; - mix-blend-mode: multiply; - z-index: 5; - opacity: 0; +.book-list .book-info .book-tags, +.book-list .book-info .book-found { + padding: 0; + font-size: var(--fs-small); } -.reader-footer { - height: 31px; - flex-shrink: 0; +/* The bar of the selection sits under the list, in the column of the library, so that it never + covers a card. The messages go above it while it is there. */ +.selection-bar { + grid-column: 2; + grid-row: 2; display: flex; + flex-wrap: wrap; align-items: center; - justify-content: space-between; - gap: 18px; - padding: 0 23px; + gap: 8px 16px; + padding: 10px 42px; background: var(--panel); border-top: 1px solid var(--line); - font-size: 9px; - color: var(--muted); -} -.reader-footer .text-button { - font-size: 10px; - padding: 4px; + box-shadow: 0 -10px 28px #233c2314; } -.reader-footer .text-button svg { - height: 13px; +:root[data-theme='dark'] .selection-bar { + box-shadow: 0 -10px 28px #0004; } -#reader-sidebar { - width: 260px; - flex-shrink: 0; - background: var(--panel); - border-right: 1px solid var(--line); - overflow: auto; +.selection-count { + font-size: var(--fs-strong); + font-weight: 600; + color: var(--accent); + white-space: nowrap; } -.side-tabs { +.selection-actions { display: flex; + flex-wrap: wrap; align-items: center; - position: sticky; - top: 0; - z-index: 1; - background: var(--panel); - border-bottom: 1px solid var(--line); - padding: 10px 9px; - gap: 3px; + gap: 6px; + margin-left: auto; } -.side-tabs button { - flex: 1; - font-size: 11px; - padding: 8px 9px; - border-radius: 5px; - color: var(--muted); +.selection-action { + padding: 7px 11px; + gap: 7px; + font-size: var(--fs-body); + font-weight: 600; + background: var(--surface); + border: 1px solid var(--line); } -.side-tabs .active { +.selection-action svg { + width: 16px; + height: 16px; +} +.selection-action.danger { + color: var(--danger); +} +/* A toggle that is on (all that are selected are favorites, or finished): the name stays and the + look says the state, as the star of the card does. */ +.selection-action[aria-pressed='true'] { color: var(--accent); background: var(--soft); - font-weight: 600; -} -#sidebar-content { - padding: 12px 0; + border-color: var(--accent); } -.outline-item { - text-align: left; - justify-content: flex-start; - white-space: normal; - width: 100%; - font-size: 11px; - line-height: 1.6; - padding: 9px 15px; +.selection-action[aria-pressed='true'] svg { + fill: color-mix(in srgb, var(--accent) 16%, transparent); } -.side-empty { - padding: 35px 25px; - text-align: center; - color: var(--muted); - font-size: 11px; - line-height: 1.8; +:root[data-selecting] #toast { + bottom: 84px; } -.side-empty > svg { - width: 25px; - height: 25px; - margin: 10px; +@media (max-width: 1100px) { + .selection-bar { + padding: 9px 28px; + } + /* Only the icon, then: the name is the tooltip and the accessible name of the button. */ + .selection-action span { + display: none; + } + .selection-action { + padding: 8px; + } } -.side-empty h3 { - color: var(--text); - font-size: 12px; - margin-bottom: 9px; +@media (max-width: 900px) { + .selection-bar { + padding: 9px 23px; + } } -.bookmark-row { +/* The top of Removidos: what is there and the way to empty it. */ +.trash-bar { display: flex; - width: calc(100% - 24px); - margin: 3px 12px; - justify-content: flex-start; - padding: 12px; - font-size: 12px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + margin: 0 0 20px; + padding: 10px 14px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + font-size: var(--fs-body); + color: var(--muted); } -.bookmark-row svg { +.trash-bar .secondary { + padding: 8px 13px; +} +.trash-bar .secondary svg { width: 15px; height: 15px; } -.bookmark-row svg:last-child { - margin-left: auto; +/* A button that destroys: the red of the interface, with text that reads on it in both themes. */ +.danger-button { + background: var(--danger); + color: #fff; } -.bookmark-row.active { - background: var(--soft); - color: var(--accent); +.primary.danger-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 86%, #000); } -#side-add-note { - margin: 3px 15px 12px; - width: calc(100% - 30px); - font-size: 11px; +:root[data-theme='dark'] .primary.danger-button { + color: #2a1311; } -.note-card { - margin: 8px 14px; - padding: 14px; - background: var(--bg); - border: 1px solid var(--line); - border-radius: 8px; - font-size: 11px; +:root[data-theme='dark'] .primary.danger-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 86%, #fff); } -.note-card .note-page { - color: var(--accent); - font-size: 10px; - padding: 0; +/* The tags of a document, and the question before a document is deleted for good. */ +dialog .tag-owner { + margin: -6px 0 10px; + color: var(--text); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.note-page svg { - width: 12px; - height: 12px; +.tag-entry { + display: flex; + gap: 8px; } -.note-card p { - line-height: 1.7; - white-space: pre-wrap; - overflow-wrap: anywhere; - margin: 10px 0; +.tag-entry .text-field { + flex: 1; } -.note-card blockquote { - margin: 10px 0; - padding-left: 9px; - border-left: 2px solid var(--accent); - color: var(--muted); - font-size: 10px; - line-height: 1.7; - overflow-wrap: anywhere; +.tag-entry .secondary { + padding: 9px 14px; } -.note-card > .text-button { - font-size: 10px; - padding: 4px 0; +dialog :is(.tag-hint, .field-hint) { + min-height: 20px; + margin-top: 6px; + font-size: var(--fs-body); + color: var(--danger); } -#find-bar { +.history-view .field-hint { + min-height: 0; + margin: 0; +} +.history-view .field-hint:not(:empty) { + margin: 0 0 10px; +} +.tag-chips { display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.tag-chip { + display: inline-flex; align-items: center; - gap: 7px; - padding: 8px 22px; - background: var(--panel); - border-bottom: 1px solid var(--line); - flex-shrink: 0; + gap: 2px; + max-width: 100%; + padding: 2px 2px 2px 11px; + font-size: var(--fs-body); + background: var(--soft); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); + border-radius: 999px; } -#find-bar .search-input { - width: 300px; - height: 31px; +.tag-chip span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -#find-count { - font-size: 11px; +.tag-chip button { + width: 24px; + height: 24px; + padding: 6px; + border-radius: 50%; color: var(--muted); - padding: 0 8px; - min-width: 100px; } -#find-close { - margin-left: auto; +.tag-chip button svg { + width: 12px; + height: 12px; } -.loading-state { - position: absolute; - inset: 0; - z-index: 6; - background: var(--reader); - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 15px; - font-size: 12px; +.tag-chips-empty { + font-size: var(--fs-body); color: var(--muted); } -.spinner { - width: 26px; - height: 26px; - border: 2px solid var(--line); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} -@keyframes spin { - to { - transform: rotate(360deg); - } -} -.focus-mode .reader-header, -.focus-mode .reader-toolbar, -.focus-mode .reader-footer, -.focus-mode #reader-sidebar, -.focus-mode #find-bar { - display: none !important; +.tag-suggestions { + display: flex; + flex-wrap: wrap; + gap: 6px; } -.floating-button { - position: absolute; - right: 22px; - top: 16px; - z-index: 20; - background: var(--panel); +.tag-suggestion { + padding: 4px 11px; + gap: 6px; + font-size: var(--fs-body); border: 1px solid var(--line); - box-shadow: var(--shadow); - padding: 9px 12px; - font-size: 11px; - opacity: 0.3; + border-radius: 999px; + background: var(--surface); } -.floating-button:hover, -.floating-button:focus { - opacity: 1; +.tag-suggestion small { + font-size: var(--fs-label); + color: var(--muted); } -.floating-button svg { - width: 15px; - height: 15px; +.purge-notes { + margin: 12px 0 0; + padding-left: 20px; + font-size: var(--fs-body); + line-height: 1.7; + color: var(--muted); } -dialog { - border: 1px solid var(--line); - border-radius: 14px; - background: var(--panel); +.purge-notes li + li { + margin-top: 4px; +} +dialog p strong { color: var(--text); - padding: 28px; - width: 520px; - max-width: calc(100vw - 40px); - max-height: calc(100vh - 60px); - box-shadow: 0 25px 100px #0003; - overflow: auto; } -dialog::backdrop { - background: #15231c66; - backdrop-filter: blur(3px); +#popover button.danger { + color: var(--danger); } -.dialog-heading { +#popover button.danger svg { + color: inherit; +} +#reader { + position: relative; + height: 100vh; display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - margin-bottom: 18px; + flex-direction: column; + background: var(--reader); } -.dialog-heading h2 { - font-size: 21px; +.reader-top { + flex-shrink: 0; + z-index: 30; } -.dialog-heading .eyebrow { - display: block; - margin-bottom: 7px; +.reader-fullscreen .reader-top, +.reader-fullscreen .reader-footer { + position: absolute; + left: 0; + right: 0; + z-index: 40; + transition: transform var(--motion-base) var(--ease-out); + box-shadow: var(--shadow); } -.dialog-heading .quiet-label { - font-size: 11px; - margin-left: 5px; - font-weight: 400; +.reader-fullscreen .reader-top { + top: 0; + transform: translateY(-100%); } -dialog p { - font-size: 12px; - line-height: 1.7; - color: var(--muted); +.reader-fullscreen .reader-footer { + bottom: 0; + transform: translateY(100%); } -.dialog-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - margin-top: 25px; +.reader-fullscreen.reader-top-visible .reader-top, +.reader-fullscreen.reader-bottom-visible .reader-footer { + transform: translateY(0); } -.dialog-actions .primary, -.dialog-actions .secondary { - font-size: 12px; - padding: 9px 17px; +/* The full screen puts the footer away, and the progress with it: a thin line on the bottom edge + keeps it in view. It is under the footer, which covers it when the pointer calls the bar. */ +.reading-line { + display: none; + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 35; + height: 3px; + background: var(--panel); + pointer-events: none; } -.dialog-actions .danger { - margin-right: auto; +.reading-line i { + display: block; + height: 100%; + width: var(--read, 0%); + background: var(--accent); } -.field-label { +.reader-fullscreen .reading-line { display: block; - font-size: 11px; - font-weight: 600; - margin: 22px 0 8px; } -.text-field, -textarea { - width: 100%; - padding: 11px 12px; - font-size: 13px; +/* Aparência: the button of the toolbar, with its name beside the icon while the window is wide. A dot + on the icon says that the brightness or the warm screen is changed (its name says by how much). */ +.reading-quick { + height: 32px; + padding: 0 9px; + gap: 6px; + font-size: var(--fs-small); } -textarea { - resize: vertical; - min-height: 100px; - line-height: 1.7; +.reading-quick svg { + width: 17px; + height: 17px; + flex-shrink: 0; } -.setting-row { +.reading-quick[aria-expanded='true'] { + background: var(--soft); +} +.appearance-icon { + position: relative; + display: inline-flex; +} +#reader-appearance[data-adjusted] .appearance-icon::after { + content: ''; + position: absolute; + top: -2px; + right: -3px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 1.5px var(--panel); +} +#reader-appearance:hover .appearance-icon::after, +#reader-appearance[aria-expanded='true'] .appearance-icon::after { + box-shadow: 0 0 0 1.5px var(--soft); +} +/* Only the name goes; the icon keeps its dot, and the label of the button says the values. */ +@media (max-width: 1100px) { + .reading-quick .quick-name { + display: none; + } + .reading-quick { + padding: 0; + width: 32px; + } +} +/* The panel of Aparência (role "dialog"): the paper, the two sliders and the way on to the + preferences. It is wider than a menu, so that the three papers sit in a row. */ +#popover[data-owner='reader-appearance'] { + width: 296px; +} +.appearance { display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 20px 0; + flex-direction: column; + gap: 14px; + padding: 8px; } -.setting-row strong, -.setting-section > strong { - font-size: 12px; - font-weight: 600; +.appearance-heading, +.appearance-row { display: flex; align-items: center; - gap: 6px; + gap: 8px; } -.setting-row strong svg { - width: 16px; - height: 16px; +.appearance-heading { + justify-content: space-between; } -.setting-row p, -.setting-section > p { - font-size: 11px; - margin-top: 5px; +.appearance-heading strong, +.appearance-row label { + font-size: var(--fs-body); + font-weight: 600; } -.setting-row select { - font-size: 12px; +.appearance-row label { + flex: 1; } -.setting-section { - padding: 20px 0; - border-top: 1px solid var(--line); +.appearance-row output { + font-size: var(--fs-small); + font-weight: 600; + font-variant-numeric: tabular-nums; } -.setting-section .setting-row { - padding: 0 0 18px; +.appearance-label { + display: block; + margin-bottom: 6px; + font-size: var(--fs-small); + color: var(--muted); } -.paper-options { +.quick-papers { display: flex; - gap: 10px; - margin: 15px 0 10px; + gap: 6px; } -.paper-option { +#popover .quick-paper { flex: 1; - flex-direction: column; - gap: 9px; + justify-content: center; + gap: 6px; + padding: 8px 6px; border: 1px solid var(--line); - padding: 15px 10px; - font-size: 11px; background: var(--bg); } -.paper-option.active { +#popover .quick-paper:hover:not(:disabled) { + background: var(--soft); +} +#popover .quick-paper.active { border-color: var(--accent); background: var(--soft); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } -.setting-section small { - font-size: 9px; - color: var(--muted); - display: block; - line-height: 1.6; -} -input[type='range'] { - width: 100%; - padding: 0; - accent-color: var(--accent); - height: 5px; - margin: 8px 0; +#popover .quick-paper svg { + width: 15px; + height: 15px; } -.range-labels { - display: flex; - justify-content: space-between; - font-size: 9px; - color: var(--muted); - margin-top: 8px; +#popover .quick-paper.active svg { + color: inherit; } -#warm-value { - font-size: 12px; - font-weight: 600; - color: var(--accent); +#popover #quick-close { + width: 28px; + height: 28px; + padding: 5px; } -#paper-sample { - padding: 17px 19px; - border: 1px solid var(--line); - border-radius: 7px; - margin-top: 20px; - background: #fffefa; - color: #30382c; +#popover .appearance .text-button { + width: auto; + padding: 4px 6px; + font-size: var(--fs-small); } -#paper-sample strong { - display: block; - font-family: Georgia, serif; - font-size: 18px; - font-weight: 400; +#popover #quick-more { + align-self: flex-start; + padding-left: 0; } -#paper-sample span { - display: block; - font-family: Georgia, serif; - font-size: 11px; - margin-top: 7px; +#popover #quick-more svg { + width: 13px; + height: 13px; + color: inherit; } -#paper-sample[data-paper='dark'] { - background: #1e2423; - color: #e5e4df; +/* The panel scrolls, so the outline of a control would be cut at its edge: it stays inside the room + that the panel leaves around the controls. */ +.appearance :is(button, input):focus-visible { + outline-offset: 2px; } -#paper-sample[data-paper='sepia'] { - background: #eee2c9; - color: #554835; +.appearance input[type='range'] { + width: 100%; } -.switch-row { - border-top: 1px solid var(--line); - cursor: pointer; +.reader-header { + height: 57px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 0 var(--reader-inset); + background: var(--panel); + border-bottom: 1px solid var(--line); } -.switch-row input { - appearance: none; - width: 34px; - height: 20px; +.header-divider { + height: 22px; + width: 1px; background: var(--line); - border: 0; - border-radius: 20px; - position: relative; flex-shrink: 0; - cursor: pointer; + margin: 0 4px; } -.switch-row input:before { - content: ''; - position: absolute; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--panel); - left: 3px; - top: 3px; - transition: transform 0.15s; +.reader-document { + min-width: 0; + flex: 1; } -.switch-row input:checked { - background: var(--accent); +.reader-document strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--fs-body); + font-weight: 600; } -.switch-row input:checked:before { - transform: translateX(14px); +.reader-document > span { + display: block; + font-size: var(--fs-label); + color: var(--muted); + margin-top: 4px; } -.settings-folder { - background: var(--bg); - border: 1px solid var(--line); - border-radius: 7px; - padding: 13px 15px; +.reader-toolbar { display: flex; - align-items: flex-start; - flex-direction: column; - gap: 8px; + align-items: center; + justify-content: space-between; + background: var(--panel); + padding: 8px var(--reader-inset); + gap: 15px; + flex-shrink: 0; + border-bottom: 1px solid var(--line); } -.settings-folder > span { +.toolbar-group { display: flex; align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 600; -} -.settings-folder svg { - width: 14px; - height: 14px; + gap: 5px; + min-width: 0; } -.settings-folder code { - font-size: 9px; - line-height: 1.6; - overflow-wrap: anywhere; - color: var(--muted); +.toolbar-group .icon-button { + width: 32px; + height: 32px; } -.settings-folder button { - font-size: 10px; - padding: 4px 0; +.toolbar-group .icon-button svg { + width: 17px; + height: 17px; } -.shortcuts { - margin: 23px 0; - border-top: 1px solid var(--line); - padding-top: 12px; +#page-number { + width: 49px; + height: 28px; + font-size: var(--fs-body); + text-align: center; + padding: 0 3px; + appearance: textfield; + -moz-appearance: textfield; } -.shortcuts > div { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 0; - font-size: 11px; +#page-number::-webkit-inner-spin-button { + display: none; } -.guide-block { - margin-top: 23px; +#page-total { + font-size: var(--fs-small); + color: var(--muted); + white-space: nowrap; + padding: 0 5px; } -.guide-block strong { - font-size: 12px; - display: block; - margin-top: 18px; +#zoom { + font-size: var(--fs-small); + max-width: 155px; + height: 29px; + padding: 3px 8px; + border-color: transparent; + background: transparent; } -.guide-block p { - font-size: 11px; - margin-top: 6px; +.reader-body { + position: relative; + min-height: 0; + flex: 1; + display: flex; } -.small-copy { - font-size: 10px !important; - margin-bottom: 25px !important; +#viewer-wrap { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; } -.quote-preview { - margin: 12px 0; - padding: 12px 15px; - background: var(--soft); - border-left: 3px solid var(--accent); - font-size: 12px; - line-height: 1.7; - max-height: 160px; +#viewer-container { + position: absolute; + inset: 0; overflow: auto; + outline: none; + scrollbar-gutter: stable both-edges; + overscroll-behavior: contain; } -.danger { - color: var(--danger); +/* It is a stop of Tab and where the arrows act: the keyboard reader sees that it has the focus. */ +#viewer-container:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } -.error-list { - font-size: 12px; - line-height: 1.8; - overflow-wrap: anywhere; - padding-left: 20px; +#pdf-viewer { + padding: 24px 20px 32px; + min-height: 100%; } -#popover { - position: fixed; - z-index: 50; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 9px; - padding: 6px; - box-shadow: var(--shadow); - width: 248px; +.pdfViewer .page { + margin: 0 auto 20px !important; + border: 0 !important; + box-shadow: 0 2px 9px #0002; + overflow: visible !important; } -#popover button { +/* Two pages side by side: PDF.js groups each pair in a .spread. "Page width" leaves + 40px around the pair; the pair's own margins and the narrower padding fit in it. */ +#viewer-container[data-spread] #pdf-viewer { + padding-inline: 10px; +} +.pdfViewer .spread { + margin-inline: 0; +} +.pdfViewer .spread .page { + margin: 0 4px 20px !important; +} +.pdfViewer .canvasWrapper { + overflow: hidden; +} +.pdfViewer .textLayer { + opacity: 1; +} +.pdfViewer .textLayer ::selection { + background: rgb(75 137 106 / 0.28); +} +:root[data-paper='dark'] #viewer-wrap { + background: var(--paper-dark); +} +:root[data-paper='dark'] .pdfViewer .canvasWrapper { + filter: invert(0.91) hue-rotate(180deg); +} +:root[data-paper='sepia'] #viewer-wrap { + background: var(--paper-sepia); +} +:root[data-paper='sepia'] .pdfViewer .canvasWrapper { + filter: var(--paper-sepia-filter); +} +#warm-overlay { + pointer-events: none; + position: absolute; + inset: 0; + background: #ff9a35; + mix-blend-mode: multiply; + z-index: 5; + opacity: 0; +} +/* The way back after a jump floats over the reading surface, above the brightness and warm + layers, so that it never moves the PDF. The colours are the message's, inverted to the page + (dark on the light theme, light on the dark one), with an edge that keeps it apart from a + page of the same colour: the dark paper, or the sepia one. The way forward is quieter. */ +.jump-pill { + --pill-bg: var(--text); + --pill-fg: var(--panel); + position: absolute; + z-index: 8; + right: 22px; + bottom: 18px; display: flex; - justify-content: flex-start; - white-space: normal; - text-align: left; - width: 100%; - padding: 10px; - font-size: 11px; - gap: 10px; + align-items: center; + gap: 2px; + max-width: calc(100% - 44px); + padding: 3px; + color: var(--pill-fg); + background: var(--pill-bg); + border: 1px solid color-mix(in srgb, var(--pill-fg) 40%, transparent); + border-radius: 999px; + box-shadow: + 0 2px 10px #0004, + var(--shadow); + font-size: var(--fs-body); + animation: jump-pill-in var(--motion-base) var(--ease-out); +} +.jump-pill[data-side='forward'] { + --pill-bg: var(--panel); + --pill-fg: var(--text); + font-size: var(--fs-small); +} +@keyframes jump-pill-in { + from { + opacity: 0; + } } -#popover button svg { - width: 16px; - height: 16px; - color: var(--muted); +.jump-pill button { + min-width: 0; + height: 28px; + padding: 0 12px; + gap: 6px; + font-size: inherit; + font-weight: 600; + color: inherit; + border-radius: 999px; } -#popover hr { - border: 0; +.jump-pill button:hover:not(:disabled) { + background: color-mix(in srgb, currentColor 16%, transparent); +} +.jump-pill button:focus-visible { + outline-color: currentColor; + outline-offset: 1px; +} +.jump-pill svg { + width: 15px; + height: 15px; +} +.jump-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.jump-pill .jump-dismiss { + width: 28px; + padding: 0; + opacity: 0.75; +} +.jump-pill .jump-dismiss:hover:not(:disabled), +.jump-pill .jump-dismiss:focus-visible { + opacity: 1; +} +.jump-pill .jump-dismiss svg { + width: 13px; + height: 13px; +} +/* In the full screen the bottom bar comes over the reading surface when the pointer reaches the + edge: the pill stays above the strip that calls it, so that the two never fight for the pointer. */ +.reader-fullscreen .jump-pill { + bottom: 52px; +} +.reader-footer { + height: 31px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 18px; + padding: 0 var(--reader-inset); + background: var(--panel); border-top: 1px solid var(--line); - margin: 5px 3px; + font-size: var(--fs-label); + color: var(--muted); } -#toast { - position: fixed; - z-index: 100; - bottom: 22px; - left: 50%; - transform: translateX(-50%); +.reader-footer .text-button { + font-size: var(--fs-small); + min-height: 28px; + padding: 6px 10px; +} +.reader-footer .text-button svg { + height: 13px; +} +/* The width is the user's, from the preferences (--sidebar-width). The window caps it at + 40%, so that the PDF keeps most of the screen even at the smallest window. The bar + itself does not scroll: its list does, and the tabs and the drag handle stay put. */ +#reader-sidebar { + position: relative; + display: flex; + flex-direction: column; + width: min(var(--sidebar-width, 260px), 40vw); + flex-shrink: 0; + background: var(--panel); + border-right: 1px solid var(--line); +} +.side-tabs { display: flex; align-items: center; - gap: 20px; - background: var(--text); - color: var(--panel); - border-radius: 8px; - padding: 13px 18px; - font-size: 12px; - box-shadow: var(--shadow); - max-width: calc(100vw - 50px); + flex-shrink: 0; + background: var(--panel); + border-bottom: 1px solid var(--line); + padding: 10px 6px; + gap: 2px; } -#toast span { - overflow-wrap: anywhere; +.side-tabs button { + flex: 1; + min-width: 0; + font-size: var(--fs-small); + padding: 8px 2px; + border-radius: 5px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; } -#toast button { - font-size: 11px; +.side-tabs .active { + color: var(--accent); + background: var(--soft); font-weight: 600; - color: inherit; - padding: 5px 8px; - border: 1px solid #ffffff30; } -#toast button:hover { - background: #ffffff20; +#sidebar-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px 0; } -#drop-overlay { - position: fixed; - inset: 0; - z-index: 200; - background: color-mix(in srgb, var(--panel) 90%, transparent); - backdrop-filter: blur(6px); - border: 3px dashed var(--accent); - display: grid; - place-items: center; - text-align: center; - color: var(--accent); - pointer-events: none; +#sidebar-resizer { + position: absolute; + top: 0; + bottom: 0; + right: -4px; + width: 8px; + z-index: 12; + cursor: col-resize; + touch-action: none; +} +#sidebar-resizer::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 3px; + width: 2px; + background: transparent; + transition: background var(--motion-fast) var(--ease-out); } -#drop-overlay svg { - width: 60px; - height: 60px; - margin-bottom: 30px; +/* A short grip in the middle of the edge says, at rest, that the edge can be dragged. */ +#sidebar-resizer::before { + content: ''; + position: absolute; + top: 50%; + left: 2px; + width: 4px; + height: 32px; + margin-top: -16px; + border-radius: 2px; + background: var(--field-line); + transition: background var(--motion-fast) var(--ease-out); +} +#sidebar-resizer:hover::after, +#sidebar-resizer.dragging::after { + background: color-mix(in srgb, var(--accent) 60%, transparent); +} +#sidebar-resizer:hover::before, +#sidebar-resizer.dragging::before, +#sidebar-resizer:focus-visible::before { + background: var(--accent); } -#drop-overlay p { - font-size: 13px; - margin-top: 15px; +#sidebar-resizer:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 0; } -@media (min-width: 1450px) { - .book-cover { - height: 250px; - } - .card-actions { - top: 256px; - } - .book-grid { - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - } +#sidebar-resizer:focus-visible::after { + background: var(--accent); } -@media (max-width: 1100px) { - .library-shell { - grid-template-columns: 205px minmax(0, 1fr); - } - .library-sidebar { - padding-left: 11px; - padding-right: 11px; - } - .library-content { - padding: 29px 28px 0; - } - .library-top { - padding: 0 28px; - } - .library-top > .eyebrow { - font-size: 8px; - } - .library-top .search-input { - width: 230px; - } - .add-button kbd { - display: none; - } - .book-grid { - grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); - gap: 19px; - } - .book-cover { - height: 195px; - padding: 17px 20px; - } - .card-actions { - top: 201px; - } - #continue { - gap: 17px; - padding: 21px; - } - .continue-art { - width: 65px; - height: 65px; - } - .continue-info h2 { - font-size: 18px; - } - #continue > .primary { - font-size: 11px; - padding: 11px; - } - .reader-toolbar { - padding: 7px 10px; - gap: 4px; - } - .toolbar-group { - gap: 2px; - } - .toolbar-group .icon-button { - width: 29px; - padding: 6px; - } - .toolbar-group .header-divider { - margin: 0 2px; - } - #zoom { - max-width: 137px; - font-size: 10px; - } - .reader-header { - padding: 0 11px; - gap: 8px; - } - #reader-sidebar { - width: 235px; - } +.outline-tree, +.outline-tree ul { + list-style: none; + margin: 0; + padding: 0; } -@media (max-width: 900px) { - .library-shell { - grid-template-columns: 182px minmax(0, 1fr); - } - .library-sidebar { - padding: 25px 9px 16px; - } - .brand { - padding-left: 12px; - font-size: 25px; - gap: 9px; - } - .brand-mark { - width: 31px; - height: 36px; - } - .nav-item { - padding: 11px 12px; - font-size: 11px; - gap: 8px; - } - .nav-label { - font-size: 8px; - } - .local-note { - font-size: 10px; - margin-left: 11px; - } - .local-note > span:last-child { - font-size: 9px; - } - .library-content { - padding: 27px 23px 0; - } - .library-top { - height: 75px; - padding: 0 23px; - gap: 9px; - } - .library-top > .eyebrow { - display: none; +.outline-line { + display: flex; + align-items: stretch; + padding-right: 8px; +} +.outline-toggle, +.outline-spacer { + flex: none; + width: 24px; +} +.outline-toggle { + padding: 0; + color: var(--muted); +} +.outline-toggle svg { + width: 14px; + height: 14px; + transition: transform var(--motion-fast) var(--ease-out); +} +.outline-toggle[aria-expanded='true'] svg { + transform: rotate(90deg); +} +.outline-item { + flex: 1; + min-width: 0; + text-align: left; + justify-content: flex-start; + white-space: normal; + overflow-wrap: anywhere; + font-size: var(--fs-body); + line-height: 1.6; + padding: 8px 8px 8px 4px; +} +/* The section being read: weight and a bar as well as color, for both themes. */ +.outline-item[aria-current='true'] { + background: var(--soft); + color: var(--accent); + font-weight: 600; + box-shadow: inset 3px 0 0 var(--accent); +} +/* An item that points nowhere in this document looks unavailable and has no hover. The keyboard still + reaches it, and pressing it says why it does nothing. */ +.outline-item[aria-disabled='true'] { + opacity: var(--disabled-opacity); + cursor: default; +} +.outline-item[aria-disabled='true']:hover { + background: none; +} +.thumb-list { + list-style: none; + margin: 0; + padding: 0 0 4px; +} +.thumb { + flex-direction: column; + gap: 6px; + width: 100%; + padding: 10px 0 8px; + scroll-margin: 6px 0; + border-radius: 0; + font-size: var(--fs-small); + color: var(--muted); +} +/* The list scrolls and the thumbnail takes its whole width: a ring outside it would be cut at the + sides, so it is drawn inside the button. */ +.thumb:focus-visible { + outline-offset: -2px; +} +.thumb-frame { + display: block; + width: 130px; + background: var(--soft); + box-shadow: 0 1px 5px #0003; + overflow: hidden; +} +.thumb-frame canvas { + display: block; + width: 100%; + height: 100%; + background: #fff; +} +:root[data-paper='dark'] .thumb-frame canvas { + filter: invert(0.91) hue-rotate(180deg); +} +:root[data-paper='sepia'] .thumb-frame canvas { + filter: var(--paper-sepia-filter); +} +.thumb.current { + background: var(--soft); + color: var(--accent); + font-weight: 600; +} +.thumb.current .thumb-frame { + outline: 3px solid var(--accent); + outline-offset: 2px; +} +.side-empty { + padding: 35px 25px; + text-align: center; + color: var(--muted); + font-size: var(--fs-small); + line-height: 1.8; +} +.side-empty > svg { + width: 25px; + height: 25px; + margin: 10px; +} +.side-empty .spinner { + display: inline-block; + margin: 10px; +} +.side-empty h3 { + color: var(--text); + font-size: var(--fs-body); + margin-bottom: 9px; +} +.bookmark-item { + display: flex; + align-items: center; + gap: 2px; + margin: 3px 12px; +} +.bookmark-row { + display: flex; + flex: 1; + min-width: 0; + justify-content: flex-start; + padding: 12px; + font-size: var(--fs-body); +} +/* The button that takes a mark off the list: quiet at rest, and the colour of a danger on hover. */ +.bookmark-item .bookmark-remove { + width: 32px; + height: 32px; + padding: 7px; + color: var(--muted); +} +.bookmark-item .bookmark-remove svg { + width: 16px; + height: 16px; +} +.bookmark-item .bookmark-remove:hover:not(:disabled) { + color: var(--danger); +} +.bookmark-row svg { + width: 15px; + height: 15px; +} +.bookmark-row svg:last-child { + margin-left: auto; +} +/* The page being read, as in the outline: weight and a bar as well as colour. */ +.bookmark-row.active { + background: var(--soft); + color: var(--accent); + font-weight: 600; + box-shadow: inset 3px 0 0 var(--accent); +} +#side-add-note { + margin: 3px 15px 12px; + width: calc(100% - 30px); + font-size: var(--fs-small); +} +.note-card { + margin: 8px 14px; + padding: 14px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; + font-size: var(--fs-small); +} +.note-card .note-page { + color: var(--accent); + font-size: var(--fs-label); + padding: 0; +} +.note-page svg { + width: 12px; + height: 12px; +} +.note-card p { + font-size: var(--fs-strong); + line-height: 1.7; + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 10px 0; +} +.note-card blockquote { + margin: 10px 0; + padding-left: 5px; + border-left: 2px solid var(--accent); + color: var(--muted); + font-size: var(--fs-body); + line-height: 1.7; + overflow-wrap: anywhere; +} +.note-card > .text-button { + font-size: var(--fs-small); + padding: 4px 0; +} +#find-bar { + display: flex; + align-items: center; + gap: 7px; + padding: 8px var(--reader-inset); + background: var(--panel); + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} +#find-bar .search-input { + width: 300px; + height: 31px; +} +#find-count { + font-size: var(--fs-small); + color: var(--muted); + padding: 0 8px; + min-width: 100px; +} +#find-close { + margin-left: auto; +} +.loading-state { + position: absolute; + inset: 0; + z-index: 6; + background: var(--reader); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 15px; + font-size: var(--fs-body); + color: var(--muted); +} +.spinner { + width: 26px; + height: 26px; + border: 2px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); } - .library-top .search-input { - width: 100%; +} +dialog { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); + color: var(--text); + padding: 28px; + width: 520px; + max-width: calc(100vw - 40px); + max-height: calc(100vh - 60px); + box-shadow: 0 25px 100px #0003; + overflow: auto; +} +dialog::backdrop { + background: #15231c66; + backdrop-filter: blur(3px); +} +.dialog-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} +.dialog-heading .quiet-label { + font-size: var(--fs-body); + margin-left: 5px; + font-weight: 400; +} +dialog p { + font-size: var(--fs-body); + line-height: 1.7; + color: var(--muted); +} +.dialog-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 25px; +} +.dialog-actions .primary, +.dialog-actions .secondary { + padding: 9px 17px; +} +.dialog-actions .danger { + margin-right: auto; +} +.field-label { + display: block; + font-size: var(--fs-body); + font-weight: 600; + margin: 22px 0 8px; +} +.text-field, +textarea { + width: 100%; + padding: 11px 12px; + font-size: var(--fs-strong); +} +textarea { + resize: vertical; + min-height: 100px; + line-height: 1.7; +} +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px 0; +} +.setting-row strong, +.setting-section > strong { + font-size: var(--fs-body); + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} +.setting-row strong svg { + width: 16px; + height: 16px; +} +.setting-row p, +.setting-section > p { + font-size: var(--fs-body); + margin-top: 5px; +} +.setting-row select { + font-size: var(--fs-body); +} +.setting-section { + padding: 20px 0; + border-top: 1px solid var(--line); +} +.setting-section .setting-row { + padding: 0 0 18px; +} +.paper-options { + display: flex; + gap: 10px; + margin: 15px 0 10px; +} +.paper-option { + flex: 1; + flex-direction: column; + gap: 9px; + border: 1px solid var(--line); + padding: 15px 10px; + font-size: var(--fs-body); + background: var(--bg); +} +.paper-option.active { + border-color: var(--accent); + background: var(--soft); + color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} +.setting-section small { + font-size: var(--fs-body); + color: var(--muted); + display: block; + line-height: 1.6; +} +/* The box is what the pointer finds, wherever it lands in it: 24px tall, with the native track and + thumb centred in it. */ +input[type='range'] { + width: 100%; + padding: 0; + border: 0; + accent-color: var(--accent); + height: 24px; + margin: 2px 0; + cursor: pointer; +} +.range-labels { + display: flex; + justify-content: space-between; + font-size: var(--fs-body); + color: var(--muted); + margin-top: 8px; +} +#warm-value { + font-size: var(--fs-body); + font-weight: 600; + color: var(--accent); +} +#paper-sample { + padding: 17px 19px; + border: 1px solid var(--line); + border-radius: 7px; + margin-top: 20px; + background: #fffefa; + color: #30382c; +} +#paper-sample span { + display: block; + font-family: Georgia, serif; + font-size: var(--fs-strong); + line-height: 1.6; +} +#paper-sample[data-paper='dark'] { + background: #1e2423; + color: #e5e4df; +} +#paper-sample[data-paper='sepia'] { + background: var(--paper-sepia-page); + color: #4a3d2b; +} +.switch-row { + border-top: 1px solid var(--line); + cursor: pointer; +} +/* A switch inside a section has its line above it, and room on both sides of the line. */ +.setting-section .switch-row { + padding: 18px 0; +} +.switch-row input { + appearance: none; + width: 34px; + height: 20px; + background: var(--field-line); + border: 0; + border-radius: 20px; + position: relative; + flex-shrink: 0; + cursor: pointer; +} +.switch-row input:before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--panel); + left: 3px; + top: 3px; + transition: transform var(--motion-fast) var(--ease-out); +} +.switch-row input:checked { + background: var(--accent); +} +.switch-row input:checked:before { + transform: translateX(14px); +} +.settings-folder { + background: var(--bg); + border: 1px solid var(--line); + border-radius: 7px; + padding: 13px 15px; + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 8px; +} +.settings-folder > span { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--fs-body); + font-weight: 600; +} +.settings-folder svg { + width: 14px; + height: 14px; +} +.settings-folder code { + font-size: var(--fs-body); + line-height: 1.6; + overflow-wrap: anywhere; + color: var(--muted); +} +.settings-folder button { + font-size: var(--fs-body); + padding: 4px 0; +} +/* The preferences: the sections run down the left (across the top up to 800 px) and the one that is + open beside them. The dialog has one height, so that it does not change size from a section to + the next; only the open section scrolls, and "Pronto" stays in view. */ +dialog:has(.prefs-view) { + width: 720px; + height: min(640px, calc(100vh - 60px)); + overflow: hidden; +} +dialog:has(.prefs-view) > #dialog-content, +.prefs-view { + height: 100%; +} +.prefs-view { + display: flex; + flex-direction: column; +} +.prefs-view > .dialog-heading { + flex-shrink: 0; + margin-bottom: 14px; +} +.prefs-view > .dialog-actions { + flex-shrink: 0; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +.prefs-body { + display: flex; + flex: 1; + min-height: 0; + gap: 24px; +} +.prefs-tabs { + display: flex; + flex-direction: column; + flex: 0 0 164px; + gap: 2px; + padding-right: 16px; + border-right: 1px solid var(--line); +} +.prefs-tab { + justify-content: flex-start; + padding: 9px 12px; + font-size: var(--fs-strong); + color: var(--muted); +} +/* The open tab is told by colour, weight and a bar, so that colour is not all there is to it. */ +.prefs-tab.active { + color: var(--accent); + background: var(--soft); + font-weight: 600; + box-shadow: inset 3px 0 0 var(--accent); +} +.prefs-tab:focus-visible { + outline-offset: -2px; +} +.prefs-panels { + flex: 1; + min-width: 0; + overflow: auto; + scrollbar-gutter: stable; + padding: 6px 8px 6px 6px; +} +/* Each block of a section has its line above it; the first has none, and the last has no room below. */ +.prefs-panel > .setting-section, +.prefs-panel > .switch-row { + padding: 18px 0; + border-top: 1px solid var(--line); +} +.prefs-panel > :is(.setting-section, .switch-row):first-child { + padding-top: 0; + border-top: 0; +} +.prefs-panel > :is(.setting-section, .switch-row):last-child { + padding-bottom: 0; +} +.prefs-panel .setting-section > .setting-row { + padding-bottom: 10px; +} +.prefs-panel .setting-section > .setting-row:last-child { + padding-bottom: 0; +} +.prefs-panel .paper-options { + margin: 12px 0 8px; +} +.prefs-panel .paper-option { + flex-direction: row; + gap: 8px; + padding: 10px; +} +.prefs-keys { + margin: 0 0 6px; +} +.prefs-keys > div { + padding: 4px 0; +} +#prefs-shortcuts { + padding: 4px 0; +} +/* What only the Kindle uses: a group with a name of its own. In the scroll mode it is switched off, + and the note says when it is available. */ +.prefs-group { + min-inline-size: 0; + margin: 0; + padding: 0; + border: 0; +} +.prefs-group legend { + padding: 0; + font-size: var(--fs-body); + font-weight: 600; +} +.prefs-group .mode-description { + margin-top: 5px; +} +.prefs-group .switch-row { + padding: 14px 0 0; + border-top: 0; +} +.prefs-group .mode-description + .switch-row { + margin-top: 4px; +} +.prefs-group .switch-row + .switch-row { + margin-top: 14px; + border-top: 1px solid var(--line); +} +.prefs-group:disabled .switch-row, +.prefs-group:disabled .switch-row input { + cursor: default; +} +.prefs-group:disabled .switch-row { + opacity: var(--disabled-opacity); +} +@media (max-width: 800px) { + .prefs-body { + flex-direction: column; + gap: 12px; + } + .prefs-tabs { + flex: none; + flex-direction: row; + padding: 0; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .prefs-tab { + flex: 1; + min-width: 0; + justify-content: center; + border-radius: 7px 7px 0 0; + } + .prefs-tab.active { + box-shadow: inset 0 -3px 0 var(--accent); + } +} +.shortcuts { + margin: 4px 0 0; +} +.shortcuts > div { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 6px 0; + font-size: var(--fs-body); +} +.shortcuts dd { + margin: 0; + flex-shrink: 0; + text-align: right; +} +.shortcuts kbd { + font-size: var(--fs-body); +} +/* The guide: a heading for each topic, and a few sentences under it. */ +.guide-view h3 { + font-size: var(--fs-strong); + margin-top: 22px; +} +.guide-view p { + font-size: var(--fs-strong); + margin-top: 6px; +} +.guide-view .dialog-heading + h3 { + margin-top: 6px; +} +.small-copy { + font-size: var(--fs-body) !important; + margin-top: 22px !important; +} +/* The title takes the focus when the dialog opens, so that the start of it is what is in view. */ +dialog h2[tabindex='-1']:focus { + outline: none; +} +/* A long dialog keeps its title, with the way out, and its main action in view: only the middle + scrolls. The offsets undo the padding of the dialog, so that nothing shows above or below them. */ +.scroll-dialog > .dialog-heading { + position: sticky; + top: -28px; + z-index: 1; + margin-bottom: 8px; + padding: 10px 0; + background: var(--panel); +} +.scroll-dialog > .dialog-actions { + position: sticky; + bottom: -28px; + z-index: 1; + margin-top: 18px; + padding: 14px 0; + background: var(--panel); + border-top: 1px solid var(--line); +} +.heading-tools { + display: flex; + align-items: center; + gap: 8px; +} +/* The question that takes the place of the buttons of a dialog. */ +.dialog-actions .confirm-text { + margin-right: auto; + font-size: var(--fs-body); + font-weight: 600; +} +.quote-preview { + margin: 12px 0; + padding: 12px 15px; + background: var(--soft); + border-left: 3px solid var(--accent); + font-size: var(--fs-body); + line-height: 1.7; + max-height: 160px; + overflow: auto; +} +.danger { + color: var(--danger); +} +.error-list { + font-size: var(--fs-body); + line-height: 1.8; + overflow-wrap: anywhere; + padding-left: 20px; +} +/* The original text of an error, one click away. */ +.error-detail { + margin: 12px 0; + font-size: var(--fs-body); + color: var(--muted); +} +.error-detail summary { + cursor: pointer; + font-weight: 600; +} +.error-detail p { + margin-top: 8px; + overflow-wrap: anywhere; + user-select: text; +} +#popover { + position: fixed; + /* Above the toasts: a menu the reader has just opened must not hide behind a notice. */ + z-index: 110; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 9px; + padding: 6px; + box-shadow: var(--shadow); + width: 248px; + /* Placed by popover(): inside the window, under the button or above it. These limits are + only the last resort, and the inline height is what scrolls it when nothing else fits. */ + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow-y: auto; + overscroll-behavior: contain; +} +#popover button { + display: flex; + justify-content: flex-start; + white-space: normal; + text-align: left; + width: 100%; + padding: 10px; + font-size: var(--fs-small); + gap: 10px; +} +#popover button svg { + width: 16px; + height: 16px; + color: var(--muted); +} +/* The outline of the page would be cut by the scrolling panel, so the item marks itself inside. */ +#popover [role='menuitem']:focus-visible { + background: var(--soft); + outline-offset: -2px; +} +/* The key of an action, at the end of its line. */ +#popover button kbd { + margin-left: auto; + font-size: var(--fs-micro); +} +#popover hr { + border: 0; + border-top: 1px solid var(--line); + margin: 5px 3px; +} +/* While a dialog is open the container is a child of it (placeToasts), because the top layer of a + modal dialog is above any z-index and leaves the rest of the page inert. It keeps `position: fixed`: + the dialog must not get a transform, a filter or `contain`, which would make it the box that this + one is placed in. */ +#toast { + position: fixed; + z-index: 100; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + /* The messages pile up, the newest nearest the edge. The gaps between them let the clicks through. */ + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: max-content; + max-width: calc(100vw - 50px); + pointer-events: none; + /* The red of an error, on the dark message of the light theme and on the light one of the dark. */ + --toast-danger: #f2a7a0; +} +:root[data-theme='dark'] #toast { + --toast-danger: #a0443d; +} +/* Over a page the messages go to the corner, above the footer, and not to the middle of the text. */ +body:has(#reader:not([hidden])) #toast { + left: auto; + right: 22px; + bottom: 44px; + transform: none; + align-items: flex-end; +} +/* Inside a dialog they go to its top edge (--toast-top, set by placeToasts), and not to the foot of + the window, which is where the buttons of the dialog are. Both selectors, because the one for the + reader above is the more specific. */ +#dialog > #toast, +body:has(#reader:not([hidden])) #dialog > #toast { + top: var(--toast-top, 22px); + bottom: auto; + left: 50%; + right: auto; + transform: translateX(-50%); + align-items: center; + max-width: min(480px, calc(100vw - 50px)); +} +.toast { + pointer-events: auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + max-width: 100%; + background: var(--text); + color: var(--panel); + border-radius: 8px; + padding: 7px 8px 7px 16px; + font-size: var(--fs-body); + line-height: 1.45; + box-shadow: var(--shadow); + animation: toast-in var(--motion-base) var(--ease-out); + transition: + opacity var(--motion-fast) var(--ease-out), + transform var(--motion-fast) var(--ease-out); +} +.toast[data-kind='error'] { + padding-left: 19px; + box-shadow: + inset 3px 0 0 var(--toast-danger), + var(--shadow); +} +.toast.leaving { + opacity: 0; + transform: translateY(6px); +} +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } +} +.toast-text { + flex: 1 1 auto; + min-width: 0; + padding: 5px 0; + overflow-wrap: anywhere; +} +.toast button { + font-size: var(--fs-small); + font-weight: 600; + color: inherit; + padding: 5px 9px; + border: 1px solid color-mix(in srgb, currentColor 30%, transparent); +} +.toast button:hover:not(:disabled) { + background: color-mix(in srgb, currentColor 14%, transparent); +} +/* The accent of the page is too faint on the dark message. */ +.toast button:focus-visible { + outline-color: currentColor; + outline-offset: 2px; +} +.toast .toast-close { + width: 26px; + height: 26px; + padding: 0; + border-color: transparent; +} +.toast .toast-close svg { + width: 14px; + height: 14px; +} +.toast-detail { + flex: 1 0 100%; + max-height: 5.8em; + margin: 0 8px 5px 0; + padding: 7px 9px; + overflow: auto; + background: color-mix(in srgb, currentColor 10%, transparent); + border-radius: 6px; + font: + var(--fs-small) / 1.45 ui-monospace, + 'Cascadia Mono', + Consolas, + monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} +#drop-overlay { + position: fixed; + inset: 0; + z-index: 200; + background: color-mix(in srgb, var(--panel) 90%, transparent); + backdrop-filter: blur(6px); + border: 3px dashed var(--accent); + display: grid; + place-items: center; + text-align: center; + color: var(--accent); + pointer-events: none; +} +#drop-overlay svg { + width: 60px; + height: 60px; + margin-bottom: 30px; +} +#drop-overlay p { + font-size: var(--fs-strong); + margin-top: 15px; +} +@media (min-width: 1450px) { + :root { + --cover-h: 250px; } - .heading-row { - align-items: flex-start; + .book-grid { + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); } - .heading-row > .quiet-label { - display: none; +} +@media (max-width: 1100px) { + .library-shell { + grid-template-columns: 205px minmax(0, 1fr); + } + .library-sidebar { + padding-left: 11px; + padding-right: 11px; } - h1 { - font-size: 29px; + .library-content { + padding: 26px 28px 28px; } - .heading-row p { - font-size: 12px; + .library-top { + padding: 0 28px; + } + .add-button kbd { + display: none; } .book-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); + gap: 19px; } - .continue-art { - display: none; + :root { + --cover-h: 195px; + --reader-inset: 10px; + } + .book-cover { + padding: 17px 20px; } #continue { - flex-wrap: wrap; - gap: 17px; + gap: 14px; + padding: 10px 14px; + } + #continue > .primary { + font-size: var(--fs-small); + padding: 8px 12px; + } + .reader-toolbar { + padding: 7px var(--reader-inset); + gap: 4px; + } + .toolbar-group { + gap: 2px; + } + .toolbar-group .icon-button { + width: 29px; + padding: 6px; + } + .toolbar-group .reading-quick { + width: 29px; + } + .toolbar-group .header-divider { + margin: 0 2px; + } + #zoom { + max-width: 137px; + font-size: var(--fs-label); + } + .reader-header { + gap: 8px; } - .continue-info { - min-width: 200px; +} +@media (max-width: 900px) { + .library-shell { + grid-template-columns: 182px minmax(0, 1fr); + } + .library-sidebar { + padding: 25px 9px 16px; + } + .brand { + padding-left: 12px; + font-size: 25px; + gap: 9px; + } + .brand-mark { + width: 31px; + height: 36px; } - .continue-info h2 { - font-size: 18px; + .nav-item { + padding: 11px 12px; + font-size: var(--fs-small); + gap: 8px; + } + .library-content { + padding: 24px 23px 24px; + } + :root { + --library-top-h: 75px; + } + .library-top { + padding: 0 23px; + gap: 9px; + } + .heading-row p { + font-size: var(--fs-body); } - .library-footer > span { - max-width: 270px; - font-size: 9px; - line-height: 1.7; + .book-grid { + grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); } .reader-toolbar { gap: 0; padding: 7px; } - .toolbar-group .icon-button { + .toolbar-group .icon-button, + .toolbar-group .reading-quick { width: 28px; } .toolbar-group .header-divider { @@ -1717,147 +2881,832 @@ input[type='range'] { #zoom { max-width: 123px; } - .reader-toolbar #fullscreen { - display: none; - } - .reader-header #restart { - font-size: 9px; - } #page-number { width: 40px; } #page-total { padding: 0 3px; } - #reader-sidebar { - width: 220px; + .search-input kbd { + display: none; + } +} +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + scroll-behavior: auto !important; + } + .spinner, + .toast, + .jump-pill { + animation: none; } - .reader-footer { - padding: 0 15px; +} +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--muted) 28%, transparent); + border: 3px solid transparent; + background-clip: padding-box; + border-radius: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} + +/* A single non-interactive layer dims the page without PDF rerenders. */ +#brightness-overlay { + pointer-events: none; + position: absolute; + inset: 0; + background: #000; + z-index: 5; + opacity: 0; +} +.setting-label { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--fs-body); + font-weight: 600; +} +.setting-label svg { + width: 16px; + height: 16px; +} +#brightness-value { + font-size: var(--fs-body); + font-weight: 600; + color: var(--accent); + min-width: 36px; + text-align: right; +} +.brightness-reset { + font-size: var(--fs-body); + margin-top: 12px; + padding: 4px 0; +} +#paper-sample { + position: relative; + isolation: isolate; + overflow: hidden; +} +#paper-sample::before, +#paper-sample::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; +} +#paper-sample::before { + background: #ff9a35; + mix-blend-mode: multiply; + opacity: var(--sample-warm, 0); +} +#paper-sample::after { + background: #000; + opacity: var(--sample-dim, 0); +} + +/* Keep PDF.js' lazy page rendering; Kindle only changes how its viewport moves. */ +#viewer-container[data-reading-mode='kindle'] { + overflow-y: hidden; + touch-action: pan-x; + scroll-behavior: auto; + overflow-anchor: none; +} +#viewer-container[data-reading-mode='kindle'] #pdf-viewer { + padding-top: 0; + padding-bottom: var(--chapter-end-padding, 0px); +} +#viewer-container[data-reading-mode='kindle'] .page { + margin-bottom: 0 !important; + box-shadow: none; +} +.reading-mode-button { + padding: 6px 10px; + font-size: var(--fs-body); + border: 1px solid var(--line); + flex-shrink: 0; +} +.reading-mode-button svg { + width: 16px; + height: 16px; +} +.reading-mode-button.selected { + color: var(--accent); + background: var(--soft); + border-color: var(--accent); +} +/* The Kindle button of the toolbar is as tall as the buttons around it. */ +.toolbar-group .reading-mode-button { + height: 32px; +} +/* The goal of the day sits at the end of the line of the title. */ +.library-goal { + display: block; + flex: 0 1 auto; + min-width: 0; + max-width: 50%; + margin: 0; + padding: 8px 14px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--accent); + font-size: var(--fs-body); + text-align: left; +} +.library-goal > span:first-child { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* The day's progress under the text of the goal: a thin gauge, like the ones of the cards. */ +.library-goal .goal-track { + display: block; + height: 3px; + margin-top: 6px; + background: var(--line); + border-radius: 4px; + overflow: hidden; +} +.library-goal .goal-track i { + display: block; + height: 100%; + width: var(--goal, 0%); + background: var(--accent); + border-radius: 4px; +} +/* The goals dialog has two parts, each in a box of its own with its choice, its numbers, the day's + progress and its own "Salvar": the general goal and, from a document, the one of that document. */ +.goal-rule { + margin-bottom: 4px; +} +.goal-part { + min-inline-size: 0; + margin: 16px 0 0; + padding: 2px 16px 16px; + border: 1px solid var(--line); + border-radius: 10px; +} +.goal-part legend { + padding: 0 6px; + font-size: var(--fs-strong); + font-weight: 600; +} +.goal-part .field-label { + margin: 14px 0 6px; +} +.goal-doc-name { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.goal-choices { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +/* The progress of the day sits at the left of the button, so that each part is short. */ +.goal-foot { + display: flex; + align-items: flex-end; + gap: 16px; + margin-top: 16px; +} +.goal-foot .primary { + flex: none; + margin-left: auto; + padding: 9px 17px; +} +.goal-status { + flex: 1; + min-width: 0; + display: grid; + gap: 8px; +} +.goal-status:empty { + display: none; +} +.goal-today { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-size: var(--fs-body); + color: var(--muted); +} +.goal-today strong { + color: var(--accent); + font-weight: 600; + text-align: right; +} +.goal-status progress { + width: 100%; + height: 8px; + accent-color: var(--accent); +} +.goal-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +.goal-fields > div { + min-width: 0; +} +.goal-fields .text-field, +#goal-log-book { + max-width: 100%; + width: 100%; +} +/* The two folded parts at the end, each under a line of its own. */ +.goal-adjust, +.goal-rules { + border-top: 1px solid var(--line); + padding-top: 14px; + margin-top: 16px; +} +.goal-rules { + font-size: var(--fs-body); + color: var(--muted); +} +dialog:has(.history-view) { + width: 720px; +} +.history-view .field-label { + margin-top: 12px; +} +.history-month { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin: 0; +} +.history-controls { + display: flex; + align-items: end; + gap: 18px; + margin-bottom: 10px; +} +.history-controls > div:first-child { + flex: 1; + min-width: 0; +} +.history-controls select { + width: 100%; + max-width: 100%; +} +.history-view .dialog-heading { + position: sticky; + top: -28px; + background: var(--panel); + padding: 10px 0; + z-index: 1; + margin-bottom: 8px; +} +.history-month input { + width: 210px; + text-align: center; +} +.history-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 18px; +} +.history-stats > div { + display: grid; + gap: 5px; + padding: 14px; + border-radius: 9px; + background: var(--soft); +} +.history-stats strong { + font-size: var(--fs-title); + color: var(--accent); +} +.history-stats span { + font-size: var(--fs-body); + color: var(--muted); +} +.history-week, +.history-calendar { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 5px; +} +.history-week { + margin-bottom: 7px; + text-align: center; + font-size: var(--fs-body); + color: var(--muted); +} +.history-day { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + padding: 8px 5px; + min-height: 62px; + align-items: stretch; + justify-content: flex-start; + border: 1px solid var(--line); + border-radius: 7px; + text-align: left; + background: var(--panel); + font-size: var(--fs-body); +} +.history-day > strong { + display: flex; + align-items: center; + justify-content: space-between; + gap: 3px; +} +.history-day > span, +.history-day small { + font-size: var(--fs-label); + overflow-wrap: anywhere; + color: var(--muted); +} +.history-day.met { + background: var(--soft); +} +.history-day.met small { + color: var(--accent); + font-weight: 600; +} +.history-day.missed { + border-bottom: 3px solid var(--missed); +} +.history-day.ongoing { + border-bottom: 3px solid var(--accent); +} +.history-day[aria-pressed='true'] { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.history-day.future { + opacity: var(--disabled-opacity); +} +/* The focus walks the days with the arrows, and the selected day has its own ring: the one of the + focus is wider and further out, so that the two are told apart when they are on the same day. */ +.history-day:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 2px; +} +.history-today { + font-size: var(--fs-label); + font-weight: 400; + color: var(--accent); +} +.history-detail { + border-top: 1px solid var(--line); + margin-top: 22px; + padding-top: 18px; +} +.history-detail-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.history-detail h3 { + font-size: var(--fs-strong); +} +.history-status { + font-size: var(--fs-body); + color: var(--muted); +} +.history-status.met { + color: var(--accent); + font-weight: 600; +} +.history-day-totals { + display: flex; + gap: 20px; + font-size: var(--fs-strong); + margin: 14px 0; +} +.history-books { + list-style: none; + padding: 0; + margin: 0; +} +.history-books li { + display: flex; + gap: 12px; + justify-content: space-between; + padding: 9px 0; + border-bottom: 1px solid var(--line); + font-size: var(--fs-body); +} +.history-books li > span:first-child { + min-width: 0; + overflow-wrap: anywhere; +} +.history-books li > span:last-child { + white-space: nowrap; + color: var(--muted); +} +.goal-rules summary, +.goal-adjust summary { + cursor: pointer; + font-weight: 600; +} +.goal-adjust .secondary { + margin-top: 16px; +} +#reader-goal { + overflow: hidden; + text-overflow: ellipsis; + display: block; + max-width: 40%; + color: var(--accent); +} +.reader-footer > .text-button { + flex-shrink: 0; +} +.reading-status { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +#reading-progress { + flex: none; + white-space: nowrap; +} +/* The estimate yields first, so it never pushes the goal or the note button. */ +#reading-time { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#reading-time::before { + content: '·'; + margin-right: 6px; +} +/* The footer starts with the progress, so that it does not move with what comes after it; the goal + and the note stay at the right edge. */ +#reader-goal { + margin-left: auto; +} +.mode-description { + font-size: var(--fs-body); + line-height: 1.7; + color: var(--muted); + margin-top: 10px; +} +#pref-spread-row select { + flex-shrink: 0; +} +#pref-spread:disabled { + opacity: var(--disabled-opacity); +} +@media (max-width: 900px) { + .reading-mode-button { + font-size: var(--fs-small); + padding: 6px 8px; } - .search-input kbd { + .reading-mode-button svg { display: none; } } -@media (prefers-reduced-motion: reduce) { - * { - transition: none !important; - scroll-behavior: auto !important; - } - .spinner { - animation: none; - } + +/* Highlights and notes in the text. The four colours: a solid one for swatches and menus, one that + is multiplied over the light and the sepia paper (the text stays as dark as it was), and one + for the dark paper, which is the page with its colours inverted: there a multiplied colour would + turn black, so the mark is a translucent light over it. */ +:root { + --hl-yellow: #ffe45c; + --hl-green: #9fe5ae; + --hl-blue: #9bcdfd; + --hl-pink: #ffadcb; + --hl-yellow-night: rgb(255 214 10 / 0.34); + --hl-green-night: rgb(76 217 100 / 0.3); + --hl-blue-night: rgb(80 170 255 / 0.36); + --hl-pink-night: rgb(255 105 160 / 0.36); +} +[data-color='yellow'] { + --sw: var(--hl-yellow); + --mark-night: var(--hl-yellow-night); + --pin: #f2c200; +} +[data-color='green'] { + --sw: var(--hl-green); + --mark-night: var(--hl-green-night); + --pin: #3fae63; +} +[data-color='blue'] { + --sw: var(--hl-blue); + --mark-night: var(--hl-blue-night); + --pin: #3a8fe0; +} +[data-color='pink'] { + --sw: var(--hl-pink); + --mark-night: var(--hl-pink-night); + --pin: #e0568f; +} +/* One layer for each page, inside the page of PDF.js and above its text. It ignores the pointer, so + that the text can be selected and the links followed; only the marks of notes are buttons. The + layer itself makes no stacking context: the blend of the marks needs the page behind them. */ +.note-layer { + position: absolute; + inset: 0; + overflow: clip; + pointer-events: none; + -webkit-user-select: none; + user-select: none; } -::-webkit-scrollbar { - width: 10px; - height: 10px; +.note-marks { + position: absolute; + inset: 0; + mix-blend-mode: multiply; } -::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--muted) 28%, transparent); - border: 3px solid transparent; - background-clip: padding-box; - border-radius: 10px; +.note-mark { + position: absolute; + border-radius: 2px; + background: var(--sw); } -::-webkit-scrollbar-track { - background: transparent; +:root[data-paper='dark'] .note-marks { + mix-blend-mode: normal; } - -/* A single non-interactive layer dims the page without PDF rerenders. */ -#brightness-overlay { - pointer-events: none; +:root[data-paper='dark'] .note-mark { + background: var(--mark-night); +} +/* The highlight that a jump arrived at, for a moment. */ +.note-mark[data-flash] { + outline: 2px solid #2f7bd9; + outline-offset: 1px; + animation: note-flash 1.8s ease-out forwards; +} +@keyframes note-flash { + 60% { + outline-color: #2f7bd9; + } + to { + outline-color: transparent; + } +} +.note-pins { position: absolute; inset: 0; - background: #000; - z-index: 5; - opacity: 0; } -.setting-label { +/* The mark of a note with text: in the right margin, at the height of the first line. */ +.note-pin { + position: absolute; + right: 3px; + width: 18px; + height: 18px; + padding: 0; + color: #1b1b1b; + background: var(--pin); + border: 1px solid #fff9; + border-radius: 50%; + box-shadow: 0 1px 4px #0004; + transform: translateY(-50%); + pointer-events: auto; + cursor: pointer; +} +.note-pin:hover:not(:disabled) { + background: var(--pin); + transform: translateY(-50%) scale(1.14); +} +.note-pin:focus-visible { + outline: 2px solid #2f7bd9; + outline-offset: 2px; + box-shadow: 0 0 0 4px #fffc; +} +.note-pin svg { + width: 11px; + height: 11px; +} +#viewer-container[data-note-hover] .textLayer, +#viewer-container[data-note-hover] .textLayer :is(span, br) { + cursor: pointer; +} +/* The bar near the end of a selection. */ +.highlight-bar { + position: fixed; + z-index: 60; display: flex; align-items: center; + gap: 2px; + padding: 4px; + max-width: calc(100vw - 16px); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: + 0 4px 16px #0003, + var(--shadow); + animation: jump-pill-in var(--motion-fast) var(--ease-out); +} +.hl-group { + display: flex; + gap: 1px; +} +.hl-swatch { + width: 28px; + height: 28px; + padding: 0; + border-radius: 50%; +} +.hl-swatch > span { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--sw); + border: 1px solid color-mix(in srgb, var(--text) 28%, transparent); +} +/* The colour that the shortcut uses, and the colour of the highlight in its menu. */ +.hl-swatch[data-last] > span, +.hl-swatch[data-current] > span { + box-shadow: + 0 0 0 2px var(--panel), + 0 0 0 3.5px var(--text); +} +.hl-divider { + width: 1px; + height: 20px; + margin: 0 4px; + background: var(--line); +} +.hl-action { + height: 28px; + padding: 0 9px; gap: 6px; - font-size: 12px; + font-size: var(--fs-small); font-weight: 600; } -.setting-label svg { - width: 16px; - height: 16px; +.hl-action svg { + width: 15px; + height: 15px; } -#brightness-value { - font-size: 12px; - font-weight: 600; - color: var(--accent); - min-width: 36px; - text-align: right; +#popover .menu-colors { + display: flex; + gap: 6px; + padding: 4px 8px; } -.brightness-reset { - font-size: 10px; - margin-top: 12px; +#popover .menu-colors .hl-swatch { + width: 30px; + height: 30px; + padding: 0; + justify-content: center; +} +/* The notes of the side bar. */ +.notes-search { + margin: 0 15px 8px; + height: 32px; +} +.notes-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 0 15px 2px; + font-size: var(--fs-label); + color: var(--muted); +} +.notes-summary .text-button { + font-size: var(--fs-small); padding: 4px 0; } -#paper-sample { - position: relative; - isolation: isolate; - overflow: hidden; +.note-card[data-color] { + border-left: 3px solid var(--sw); } -#paper-sample::before, -#paper-sample::after { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; +.note-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin: -4px -6px 0 0; } -#paper-sample::before { - background: #ff9a35; - mix-blend-mode: multiply; - opacity: var(--sample-warm, 0); +.note-head .note-page { + padding: 4px 0; + gap: 6px; + text-align: left; } -#paper-sample::after { - background: #000; - opacity: var(--sample-dim, 0); +.note-swatch { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--sw); + border: 1px solid color-mix(in srgb, var(--text) 28%, transparent); } - -/* Keep PDF.js' lazy page rendering; Kindle only changes how its viewport moves. */ -#viewer-container[data-reading-mode='kindle'] { - overflow-y: hidden; - touch-action: pan-x; - scroll-behavior: auto; - overflow-anchor: none; +.note-head .note-more { + width: 26px; + height: 26px; + padding: 4px; } -#viewer-container[data-reading-mode='kindle'] #pdf-viewer { - padding-top: 0; - padding-bottom: 0; +.note-head .note-more svg { + width: 15px; + height: 15px; } -#viewer-container[data-reading-mode='kindle'] .page { - margin-bottom: 0 !important; - box-shadow: none; +/* The quote is a button that takes to the passage: it reads as the text it holds, and answers to + the pointer and the keyboard like the "Página N" link beside it. Six lines at most. */ +.note-card .note-quote { + display: block; + width: 100%; + min-width: 0; + padding: 2px 4px; + text-align: left; + white-space: normal; + font: inherit; + line-height: inherit; + color: inherit; + border-radius: 4px; } -.reading-mode-button { - padding: 6px 10px; - font-size: 12px; +.note-card .note-quote:hover:not(:disabled) { + color: var(--text); +} +.note-card .note-quote:focus-visible { + outline-offset: 1px; +} +.note-quote > span { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 6; + overflow: hidden; +} +.note-card p.note-kind { + margin: 8px 0; + font-size: var(--fs-label); + font-weight: 600; + color: var(--muted); +} +/* The choice of colour in the dialog of a note that is a highlight. */ +.color-field { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} +.color-field legend { + display: block; + padding: 0; +} +.color-choices { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +/* The same pills choose a colour and, in the goals dialog, between the settings of a goal. */ +.color-choice, +.goal-choice { + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 12px 6px 7px; + font-size: var(--fs-body); border: 1px solid var(--line); - flex-shrink: 0; + border-radius: 999px; + cursor: pointer; } -.reading-mode-button svg { +.color-choice input, +.goal-choice input { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + cursor: pointer; +} +.color-choice .swatch { width: 16px; height: 16px; + border-radius: 50%; + background: var(--sw); + border: 1px solid color-mix(in srgb, var(--text) 28%, transparent); } -.reading-mode-button.selected { - color: var(--accent); - background: var(--soft); +.color-choice:has(input:checked), +.goal-choice:has(input:checked) { border-color: var(--accent); + background: var(--soft); } -.mode-description { - font-size: 11px; - line-height: 1.7; - color: var(--muted); - margin-top: 10px; +.color-choice:has(input:focus-visible), +.goal-choice:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; } -@media (max-width: 900px) { - .reading-mode-button { - font-size: 11px; - padding: 6px 8px; - } - .reading-mode-button svg { - display: none; +/* Text only, so no room for a swatch; the one that is on has a thicker line, besides the colour. */ +.goal-choices .goal-choice { + padding: 7px 14px; +} +.goal-choices .goal-choice:has(input:checked) { + color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} +@media (prefers-reduced-motion: reduce) { + .highlight-bar, + .note-mark[data-flash] { + animation: none; } } diff --git a/src/ui/clipboard.ts b/src/ui/clipboard.ts new file mode 100644 index 0000000..ce2be0b --- /dev/null +++ b/src/ui/clipboard.ts @@ -0,0 +1,26 @@ +// Puts text on the clipboard. Says whether it got there. The API of the browser is the way; the old +// command that copies from a field is the way when the first one is refused, and it takes +// the selection and the focus for a moment, so it gives them back. +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + /* Refused or unavailable: the old way below. */ + } + const active = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const field = document.createElement('textarea'); + try { + field.value = text; + field.setAttribute('readonly', ''); + field.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none'; + document.body.append(field); + field.select(); + return document.execCommand('copy'); + } catch { + return false; + } finally { + field.remove(); + active?.focus({ preventScroll: true }); + } +} diff --git a/src/ui/dom.ts b/src/ui/dom.ts index b1f82a1..a750222 100644 --- a/src/ui/dom.ts +++ b/src/ui/dom.ts @@ -9,3 +9,5 @@ export const size = (n: number) => n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`; export const ib = (id: string, label: string, ico: string, extra = '') => ``; +// "1 documento", "12 documentos". +export const documents = (n: number) => `${n} ${n === 1 ? 'documento' : 'documentos'}`; diff --git a/src/ui/error-text.ts b/src/ui/error-text.ts new file mode 100644 index 0000000..760ff53 --- /dev/null +++ b/src/ui/error-text.ts @@ -0,0 +1,175 @@ +// What a failure says to the reader. The backend (Rust), the system (Windows) and PDF.js each +// fail in their own words and language; the reader gets one short sentence in Portuguese and +// can open the original text behind "Detalhes". Pure: no DOM, so that it can be tested alone. +export type ErrorText = { + // The friendly sentence. + message: string; + // The technical text of the error, as it came. Empty when it adds nothing to the sentence. + detail: string; +}; +export const GENERIC_ERROR = 'Algo deu errado. Tente novamente.'; + +// What is known of an error before choosing the sentence. `text` is lower case, for the rules. +type Facts = { + text: string; + name: string; + code: number | null; + status: number | null; + missing: boolean; + os: number | null; +}; +type Rule = [matches: (facts: Facts) => boolean, say: string]; + +const field = (error: unknown, key: string): unknown => + error && typeof error === 'object' ? (error as Record)[key] : undefined; + +// The text of an error as a developer would want to read it, e.g. "InvalidPDFException: Invalid +// PDF structure." The commands of the app fail with a plain string. +export function technicalText(error: unknown): string { + let text: string; + if (error === null || error === undefined) text = ''; + else if (typeof error === 'string') text = error; + else if (error instanceof Error) text = String(error); + else if (typeof field(error, 'message') === 'string') { + const name = field(error, 'name'); + text = `${typeof name === 'string' && name ? `${name}: ` : ''}${field(error, 'message')}`; + } else { + try { + text = JSON.stringify(error) ?? String(error); + } catch { + text = String(error); + } + } + text = text.trim(); + return text.length > 2000 ? `${text.slice(0, 2000)}…` : text; +} + +function factsOf(error: unknown, technical: string): Facts { + const number = (value: unknown) => (typeof value === 'number' ? value : null); + const name = field(error, 'name'); + const os = /\(os error (\d+)\)/i.exec(technical); + return { + text: technical.toLowerCase(), + name: typeof name === 'string' ? name : '', + code: number(field(error, 'code')), + status: number(field(error, 'status')), + missing: field(error, 'missing') === true, + os: os ? Number(os[1]) : null, + }; +} + +const has = (facts: Facts, pattern: RegExp) => pattern.test(facts.text); +// Windows error numbers, from the "(os error N)" at the end of what Rust says of a failed file. +const osIn = (facts: Facts, ...numbers: number[]) => + facts.os !== null && numbers.includes(facts.os); + +// The first rule that matches decides, so the specific ones come first. +const RULES: Rule[] = [ + // PDF.js: a password. + [ + (f) => (f.name === 'PasswordException' && f.code === 2) || has(f, /incorrect password/), + 'A senha informada está incorreta.', + ], + [ + (f) => f.name === 'PasswordException' || has(f, /no password given|password required/), + 'Este PDF está protegido por senha.', + ], + // PDF.js and the import: not a PDF, or one that cannot be read. + [ + (f) => + f.name === 'InvalidPDFException' || + f.name === 'FormatError' || + has(f, /invalid pdf|invalid or corrupt|não contém um pdf válido/), + 'Este arquivo não é um PDF válido ou está danificado.', + ], + // A copy that is gone from the library, or from the disk. + [ + (f) => f.missing || f.status === 404 || has(f, /missing pdf|pdf não encontrado/), + 'Este PDF não está mais na biblioteca.', + ], + // The reading was cut: the file changed, the window closed, the transport gave up. + [ + (f) => + f.name === 'AbortException' || + f.name === 'ResponseException' || + has( + f, + /não foi possível ler parte do pdf|alterada ou está incompleta|worker was (destroyed|terminated)|loading aborted|unexpected server response|failed to fetch|networkerror|load failed/, + ), + 'A leitura do PDF foi interrompida. Tente abrir novamente.', + ], + // The commands of the app (library.rs, commands.rs, reading.rs). + [(f) => has(f, /selecione um arquivo \.pdf/), 'Só arquivos .pdf podem ser adicionados.'], + [(f) => has(f, /não é um arquivo/), 'Escolha um arquivo, não uma pasta.'], + [(f) => has(f, /arquivo está vazio/), 'O arquivo está vazio.'], + [ + (f) => has(f, /escolha uma pasta fora da biblioteca/), + 'Escolha uma pasta fora da biblioteca para exportar a cópia.', + ], + [(f) => has(f, /título entre 1 e 500/), 'Use um título entre 1 e 500 caracteres.'], + [ + (f) => has(f, /limite de notas excedido/), + 'Limite de notas excedido. Encurte as notas ou apague algumas.', + ], + [(f) => has(f, /capa inválida/), 'Não foi possível guardar a capa deste PDF.'], + [(f) => has(f, /a meta deve estar entre/), 'A meta deve estar entre 1 e 10.000.'], + [ + (f) => has(f, /data do histórico inválida|informe a data da alteração da meta/), + 'Não foi possível salvar a meta. Confira a data e tente novamente.', + ], + [ + (f) => has(f, /histórico diário excede/), + 'O histórico de leitura chegou ao limite de 100 anos.', + ], + [(f) => has(f, /registro diário inválido/), 'Não foi possível salvar o registro de leitura.'], + [ + (f) => has(f, /não foi possível ler o catálogo|catálogo e backup ilegíveis/), + 'Não foi possível ler o catálogo da biblioteca. Seus PDFs estão preservados.', + ], + [ + (f) => has(f, /poison/), + 'A biblioteca encontrou um erro interno. Feche o Fólio e abra novamente.', + ], + // The system, whatever its language: Windows says "(os error N)" in the system language. + [ + (f) => osIn(f, 5) || has(f, /access is denied|acesso negado/), + 'O Windows negou o acesso ao arquivo. Confira as permissões.', + ], + [ + (f) => + osIn(f, 32, 33) || + has(f, /used by another process|em uso por outro processo|sharing violation/), + 'O arquivo está em uso por outro programa. Feche-o e tente novamente.', + ], + [ + (f) => + osIn(f, 2, 3) || + has(f, /cannot find the (file|path)|não pode encontrar o (arquivo|caminho)|no such file/), + 'O arquivo ou a pasta não foi encontrado. Ele pode ter sido movido ou apagado.', + ], + [ + (f) => + osIn(f, 39, 112) || has(f, /not enough space|no space left|espaço (insuficiente|em disco)/), + 'Não há espaço livre suficiente no disco.', + ], + [(f) => osIn(f, 19), 'O disco está protegido contra gravação.'], + [ + (f) => osIn(f, 53, 64, 67, 1231), + 'Não foi possível acessar o local de rede. Confira a conexão.', + ], + [(f) => osIn(f, 123, 161, 206), 'O nome ou o caminho do arquivo não é aceito pelo Windows.'], + [ + (f) => osIn(f, 23, 1117, 1392), + 'O Windows não conseguiu ler o arquivo. O disco ou o arquivo pode estar com problema.', + ], + [(f) => f.os !== null, 'O Windows não conseguiu concluir a operação com o arquivo.'], +]; + +export function describeError(error: unknown): ErrorText { + const technical = technicalText(error); + const facts = factsOf(error, technical); + const message = RULES.find(([matches]) => matches(facts))?.[1] ?? GENERIC_ERROR; + // The original text is shown only when it says more than the sentence. + const plain = (text: string) => text.toLowerCase().replace(/[\s.]+$/, ''); + return { message, detail: plain(technical) === plain(message) ? '' : technical }; +} diff --git a/src/ui/failures.ts b/src/ui/failures.ts new file mode 100644 index 0000000..a6478cf --- /dev/null +++ b/src/ui/failures.ts @@ -0,0 +1,16 @@ +// What a failure says about the action that failed: the sentence that comes before the reason +// (see `fail`). Here are the ones that more than one control can start; the others are written +// where the action is. +export const FAILED = { + openPdf: 'Não foi possível abrir este PDF.', + openFiles: 'Não foi possível abrir os arquivos recebidos.', + back: 'Não foi possível voltar à biblioteca.', + undo: 'Não foi possível desfazer.', + finish: 'Não foi possível atualizar a conclusão da leitura.', + goals: 'Não foi possível abrir as metas de leitura.', + history: 'Não foi possível abrir o histórico de leitura.', + folder: 'Não foi possível abrir a pasta da biblioteca.', + bookmark: 'Não foi possível salvar a marca da página.', + exportNotes: 'Não foi possível exportar as notas.', + removeNote: 'Não foi possível excluir a nota.', +}; diff --git a/src/ui/goals-dialog.ts b/src/ui/goals-dialog.ts new file mode 100644 index 0000000..0d76845 --- /dev/null +++ b/src/ui/goals-dialog.ts @@ -0,0 +1,214 @@ +import type { Book, DailyGoal, GoalUnit, Preferences } from '../types'; +import { dayCount, dayRecord, goalProgress, localDay, ownGoal, withDay } from '../reading/goals'; +import { $, esc, ib } from './dom'; +import { modal, fail, toast } from './overlays'; +import { rememberGoal } from '../reading/history'; + +const unitName = (unit: GoalUnit) => (unit === 'pages' ? 'páginas' : 'capítulos'); +export function goalLabel(goal: DailyGoal, count: number) { + return `${count} / ${goal.target} ${unitName(goal.unit)}${count >= goal.target ? ' · Meta atingida' : ''}`; +} +// What a goal says of today, in the words of the dialog: "1 de 2 capítulos". +const progressText = (goal: DailyGoal, count: number) => + `${count} de ${goal.target} ${unitName(goal.unit)}${count >= goal.target ? ' · Meta atingida' : ''}`; +// The text of today under a goal and, under it, how far the day is. Nothing when there is no goal. +const status = (goal: DailyGoal | null, count: number, id: string, label: string) => + goal + ? `
    Hoje${esc(progressText(goal, count))}
    ` + : ''; +// Two options of one choice: radio buttons that look like a pair of buttons. The one that is on +// starts the dialog when `focus` is set. +type Option = { value: string; label: string; id: string; on: boolean }; +const choice = (name: string, options: Option[], focus = false) => + `
    ${options + .map( + (o) => + ``, + ) + .join('')}
    `; +// The quantity and the unit of a goal: the same two fields for the general goal and for the one of a +// document, with the ids of `prefix`, starting from `goal`. They are only there while the goal is on. +const fields = (prefix: string, goal: DailyGoal | null, on: boolean) => + `
    `; +// The goals of the library and, when the dialog comes from a document (`initial`: its menu, the +// reader's menu or the footer of the reader), the one of that document. The document that is set up +// is told by where the dialog was opened, and is not chosen in it. +export function showGoals( + prefs: Preferences, + actions: { + // The documents of the library. + books: () => Book[]; + // All the documents that have days, also those in Removidos and those deleted for good: the + // general goal counts the reading of all of them. + counted: () => Book[]; + save: (id: string, patch: Partial) => Promise; + apply: () => void; + history: (id?: string) => void; + }, + initial?: string, +) { + let drawn = false; + const find = () => actions.books().find((b) => b.id === initial); + // The general goal is measured by all the reading of the day. A document that has a goal of its own + // is measured by its own reading, and only then does it show a progress. + const generalStatus = () => { + const { goal, count } = goalProgress(undefined, prefs.dailyGoal, actions.counted()); + return status(goal, count, 'goal-current', 'Progresso da meta geral de hoje'); + }; + const ownStatus = (book: Book) => { + const { goal, own, count } = goalProgress(book, prefs.dailyGoal, actions.counted()); + return own + ? status(goal, count, 'goal-own-current', 'Progresso da meta deste documento hoje') + : ''; + }; + const render = () => { + const books = actions.books(); + const book = find(); + // A document has two settings: no goal of its own ('inherit', which is also how the older 'none' + // is read) or one of its own. The general goal is there or it is not. + const general = prefs.dailyGoal; + const own = book ? ownGoal(book.readingGoal) : null; + modal( + `

    Metas de leitura

    ${ib('goals-close', 'Fechar metas', 'close', 'data-close')}
    +

    ${book ? 'A meta geral conta tudo o que você ler no dia. A meta própria é um alvo extra deste documento.' : 'A meta geral conta tudo o que você ler no dia. Para dar uma meta própria a um documento, use o menu dele.'}

    +
    Meta geral + ${choice( + 'goal-mode', + [ + { value: 'none', label: 'Sem meta geral', id: 'goal-mode-none', on: !general }, + { value: 'custom', label: 'Com meta geral', id: 'goal-mode-custom', on: !!general }, + ], + true, + )} + ${fields('goal', general, !!general)} +
    ${generalStatus()}
    +
    + ${ + book + ? `
    Este documento

    ${esc(book.title)}

    + ${choice('goal-own-mode', [ + { value: 'inherit', label: 'Sem meta própria', id: 'goal-own-inherit', on: !own }, + { value: 'custom', label: 'Meta própria', id: 'goal-own-custom', on: !!own }, + ])} + ${fields('goal-own', own ?? general, !!own)} +
    ${ownStatus(book)}
    +
    ` + : '' + } + ${ + books.length + ? `
    Ajustar leitura de hoje

    Use para registrar leitura feita fora do Fólio ou em capítulos não reconhecidos. Informe o total de hoje${book ? ' neste documento' : ''}, não um acréscimo.

    ${book ? '' : ``}
    ` + : '' + } +
    Como a contagem funciona

    A leitura é acompanhada mesmo sem meta. Uma página do PDF conta depois de 8 segundos com o leitor ativo, e só uma vez por dia; as telas do Kindle não são páginas. Um capítulo conta ao passar da última página lida para o capítulo seguinte, ou ao chegar ao fim do documento. Saltos não contam as páginas do meio.

    O dia muda à meia-noite do seu computador, e os totais ficam salvos no histórico. Concluir um documento não soma as páginas dele à meta. Para corrigir um total, use Ajustar leitura de hoje.

    + `, + drawn, + ); + drawn = true; + $('#goal-history').onclick = () => actions.history(book?.id); + // The quantity and the unit are only asked for while there is a goal to set. + const toggleFields = (prefix: string, on: string) => { + const custom = $(`#${on}`); + const show = () => ($(`#${prefix}-fields`).hidden = !custom.checked); + document + .querySelectorAll(`input[name="${custom.name}"]`) + .forEach((radio) => (radio.onchange = show)); + }; + toggleFields('goal', 'goal-mode-custom'); + // Each part saves on its own and the dialog stays open, with the day drawn again: the result is + // seen, and what was typed in the other part is not lost. + const paint = () => { + $('#goal-status').innerHTML = generalStatus(); + const fresh = book && find(); + if (fresh) $('#goal-own-status').innerHTML = ownStatus(fresh); + }; + $('#goal-save').onclick = async () => { + const custom = $('#goal-mode-custom').checked; + const input = $('#goal-target'); + if (custom && !input.reportValidity()) return; + try { + prefs.dailyGoal = custom + ? { + unit: $('#goal-unit').value as GoalUnit, + target: Number(input.value), + } + : null; + rememberGoal(prefs); + actions.apply(); + paint(); + toast('Meta geral salva.'); + } catch (e) { + fail(e, 'Não foi possível salvar a meta geral.'); + } + }; + if (book) { + toggleFields('goal-own', 'goal-own-custom'); + $('#goal-own-save').onclick = async () => { + const custom = $('#goal-own-custom').checked; + const input = $('#goal-own-target'); + if (custom && !input.reportValidity()) return; + try { + await actions.save(book.id, { + readingGoal: { + unit: $('#goal-own-unit').value as GoalUnit, + target: custom ? Number(input.value) : 10, + mode: custom ? 'custom' : 'inherit', + }, + }); + actions.apply(); + paint(); + toast('Meta deste documento salva.'); + } catch (e) { + fail(e, 'Não foi possível salvar a meta deste documento.'); + } + }; + } + if (!books.length) return; + // The adjustment is of the document that the dialog came from. From the library there is none, + // and the document is chosen here, in the adjustment only. + const logBook = () => { + const id = book ? book.id : $('#goal-log-book').value; + return actions.books().find((b) => b.id === id); + }; + const fill = () => { + const b = logBook(); + $('#goal-pages').value = String(b ? dayCount(b, 'pages') : 0); + $('#goal-chapters').value = String(b ? dayCount(b, 'chapters') : 0); + $('#goal-log-save').toggleAttribute('disabled', !b); + }; + fill(); + if (!book) $('#goal-log-book').onchange = fill; + // Do not apply yesterday's open form to the next day's ledger. + const openedDay = localDay(); + $('#goal-log-save').onclick = async () => { + if (openedDay !== localDay()) { + render(); + toast('O dia mudou. Confira os totais de hoje.'); + return; + } + const b = logBook(); + if (!b) return; + const pages = $('#goal-pages'), + chapters = $('#goal-chapters'); + if (!pages.reportValidity() || !chapters.reportValidity()) return; + const day = dayRecord(b, openedDay); + try { + await actions.save(b.id, { + readingDays: withDay(b, openedDay, { + ...day, + pageAdjustment: Number(pages.value) - day.pages.length, + chapterAdjustment: Number(chapters.value) - day.chapters.length, + }), + }); + actions.apply(); + // Only the totals of today change: the rest of the dialog, the focus and what was typed stay. + paint(); + fill(); + toast('Leitura de hoje atualizada.'); + } catch (e) { + fail(e, 'Não foi possível salvar o registro de leitura.'); + } + }; + }; + render(); +} diff --git a/src/ui/help-dialog.ts b/src/ui/help-dialog.ts index 9edb276..57bc54b 100644 --- a/src/ui/help-dialog.ts +++ b/src/ui/help-dialog.ts @@ -1,25 +1,103 @@ import { ib } from './dom'; import { modal } from './overlays'; +// The keys, with what each does. The first column of a row is the action, the second the keys. +const SHORTCUTS = [ + ['Ctrl + O', 'Adicionar PDFs'], + ['Ctrl + K', 'Buscar na biblioteca (no leitor, o documento é fechado antes)'], + ['Ctrl + F', 'Buscar no PDF'], + ['Ctrl + B', 'Marcar esta página'], + ['Ctrl + + / −', 'Ajustar zoom'], + ['K', 'Alternar modo Kindle'], + ['L', 'Alternar duas páginas lado a lado'], + ['← / →', 'Página anterior / seguinte (no Kindle, a tela). Com zoom alto, rolam para os lados'], + ['Page Up / Down', 'Rolar uma tela (no Kindle, voltar / avançar tela)'], + ['Espaço / Shift + Espaço', 'Avançar / voltar tela no Kindle'], + ['Home / End', 'Início / fim no Kindle'], + ['S', 'Mostrar ou esconder a barra lateral'], + ['D', 'Mudar o papel: claro, sépia e escuro, em ciclo'], + ['R', 'Girar páginas, com Desfazer (também em “Mais opções”)'], + [ + 'Shift + ← / →', + 'Selecionar o trecho por palavra (com o foco nas páginas, na rolagem e no Kindle)', + ], + ['Shift + ↑ / ↓', 'Selecionar o trecho por linha. Sem seleção, começa no primeiro texto à vista'], + ['H', 'Realçar o trecho selecionado, na última cor usada'], + ['N', 'Adicionar nota ao trecho selecionado'], + ['F11', 'Entrar / sair da tela cheia'], + ['F1 / ?', 'Abrir este guia (o “?” só com os atalhos de uma tecla ligados)'], + [ + 'Esc', + 'Fechar uma coisa por vez: menu, barra de realce, busca, seleção da biblioteca, tela cheia, ou o aviso em foco. Na busca da biblioteca e na das notas, limpa o texto primeiro', + ], + ['Alt + ← / Alt + →', 'Voltar / avançar à posição de antes do salto'], + ['Botões laterais do mouse', 'O mesmo que Alt + ← e Alt + →'], + ['Ctrl + W', 'Voltar à biblioteca'], + ['Shift + F10 / Menu', 'Abrir as opções do documento em foco'], + [ + '← → ↑ ↓ · Home / End', + 'Na biblioteca, mover o foco entre os documentos (na lista, só ↑ ↓). Tab entra na lista e sai dela', + ], + [ + 'Espaço', + 'Na biblioteca, selecionar o documento em foco (Enter abre; Shift + Espaço marca o intervalo)', + ], + [ + 'Setas · Home / End · Page Up / Down', + 'No calendário do Histórico, mover o foco por dia e semana, ir ao começo e ao fim da semana e trocar de mês (Enter ou Espaço escolhe o dia)', + ], + ['Ctrl + clique · Shift + clique', 'Selecionar documentos (Shift marca o intervalo)'], + ['Ctrl + A', 'Selecionar todos os documentos da tela'], + ['↑ / ↓ · Home / End · letra', 'Percorrer um menu de opções'], + ['Esc / Tab', 'Fechar o menu (Esc devolve o foco ao botão)'], +]; +// Each topic is a heading of its own, with a few sentences: a reader looks for one, not for all. +const TOPICS = [ + [ + '1. Traga suas leituras', + 'Escolha PDFs ou uma pasta inteira (“Adicionar pasta”), ou arraste-os para a janela. Cada PDF é copiado para a biblioteca, e o original nunca é alterado. A capa, o número de páginas e o autor aparecem sozinhos, um PDF por vez, enquanto você usa a biblioteca.', + ], + [ + '2. Leia no seu ritmo', + 'A página, a posição e o zoom são salvos automaticamente. Aparência, na barra de leitura, muda o papel (claro, sépia ou escuro), o brilho e a tela quente na hora, e “Mais preferências…” leva às demais opções; um ponto no ícone avisa que o brilho ou a tela quente estão alterados. Ative Kindle na barra de leitura (ou com a tecla K) para passar por telas, e use o botão ao lado dele (ou a tecla L) para ver duas páginas lado a lado.', + ], + [ + '3. Guarde o que importa', + 'Favorite documentos, marque páginas e anote suas ideias. Selecione um trecho no PDF, com o mouse ou com Shift + setas, e, na barra que aparece junto a ele, escolha uma das quatro cores para realçá-lo, Adicionar nota ou Copiar. Notas, realces e marcas ficam na biblioteca, separados do PDF, que nunca é alterado.', + ], + [ + '4. Acompanhe sua leitura', + 'Use Concluir leitura, no topo do leitor, para marcar um documento como concluído. Metas de leitura tem duas partes: a meta geral, que conta tudo o que você ler no dia, em qualquer documento, e, quando aberta a partir de um documento (pelo menu dele ou pelo rodapé do leitor), a parte Este documento, com Sem meta própria ou Meta própria, um alvo extra só dele que também entra na meta geral. Para configurar outro documento, abra o menu dele. O progresso de hoje aparece no rodapé do leitor, com a meta do documento, se ele tem uma, ou a meta geral; clique nele para abrir as metas, ajustar a leitura de hoje ou ver o histórico. Em Histórico de leitura, consulte o calendário, os dias com meta atingida e quantas páginas e capítulos leu em cada dia.', + ], +]; +const DETAILS = [ + [ + 'Kindle e duas páginas', + 'O Kindle passa por telas no zoom atual, com uma pequena sobreposição, e usa uma página por vez. Use ← →, Espaço ou a roda do mouse para passar as telas, e Home e End para ir ao início e ao fim. A opção Unir conteúdo das páginas reduz margens vazias no Kindle e pode ser desativada nas preferências. O texto mantém sua diagramação, e páginas digitalizadas também funcionam. Na rolagem, em telas largas, as duas páginas ficam lado a lado, e as preferências deixam a capa sozinha.', + ], + [ + 'Barras do leitor', + 'O cabeçalho tem Voltar, o título, Concluir leitura, o favorito e Mais opções (⋯). Esse menu reúne Ler do início, Girar páginas (R, com Desfazer), Meta de leitura…, Renomear na biblioteca…, Etiquetas…, Exportar uma cópia…, Exportar notas…, Preferências…, este guia (F1) e Remover da biblioteca. A barra de ferramentas tem, à esquerda, a barra lateral, a busca e a página; à direita, o zoom, o modo Kindle e as duas páginas, a marca da página, Aparência e a tela cheia. O rodapé mostra o progresso, a meta do dia e Adicionar nota.', + ], + [ + 'Zoom, tela cheia e saltos', + 'Em zoom muito alto, ← e → rolam o PDF para os lados; a barra horizontal e Shift + roda também servem. Na tela cheia, leve o mouse ao topo ou à parte inferior (ou use Tab) para mostrar os controles; uma linha fina na borda inferior mostra o progresso enquanto eles estão escondidos. Depois de um salto (sumário, link do PDF, resultado da busca, miniatura, marca, nota, número de página, Home ou End no Kindle, Ler do início), um botão sobre o PDF leva de volta ao ponto onde você estava, e Alt + ← e Alt + → voltam e avançam entre esses pontos. O botão some depois de alguns segundos.', + ], + [ + 'Realces e notas', + 'Para selecionar sem o mouse, ponha o foco nas páginas (Tab) e use Shift + ← e → para crescer a seleção por palavra, ou Shift + ↑ e ↓ por linha, na rolagem e no Kindle. Se ainda não há seleção, ela começa no primeiro texto à vista, ou onde você clicou; a tela acompanha o fim da seleção (no Kindle, vira). A seleção fica no texto das páginas. As teclas H (realçar na última cor usada, marcada na barra) e N (adicionar nota) fazem o mesmo que a barra de realce, avisam quando não há trecho selecionado, e Esc dispensa a barra e a seleção. Clique num realce, ou no ícone de nota na margem (as notas com texto), para editar a nota, mudar a cor, copiar o texto ou excluir o realce (com Desfazer). Uma seleção que passa de uma página gera um realce por página; para anotar, vale o trecho da primeira. A aba Notas da barra lateral busca no texto e nas citações, mostra a cor de cada realce, leva à página e à altura do trecho, pela citação ou por “Página N” (e Alt + ← volta) e exporta tudo em Markdown (“Exportar notas…”, também no menu do documento).', + ], + [ + 'Documentos e etiquetas', + 'Na biblioteca, Tab leva ao documento ativo (abrir, favoritar e opções); os outros ficam fora da ordem de Tab. Use as setas para trocar de documento (na grade, nas quatro direções; na lista, ↑ e ↓), Home e End para ir ao primeiro e ao último, Enter para abrir e Espaço para selecionar. Clique com o botão direito em um documento, ou use Shift + F10 com o foco nele, para abrir as opções. Em “Etiquetas…” você agrupa documentos, e cada etiqueta vira um filtro na barra lateral da biblioteca. Com Ctrl + clique, Shift + clique (intervalo) ou a caixa que aparece no cartão, você seleciona vários documentos e favorita, conclui, etiqueta ou remove todos de uma vez.', + ], + [ + 'Barra lateral do leitor', + 'A barra lateral (tecla S) reúne o sumário, que destaca a seção que você está lendo (Tab entra na lista e sai dela; as setas andam pelos itens, e → e ← abrem e fecham as seções), as miniaturas das páginas, as marcas (o botão de cada linha remove a marca, com Desfazer) e as notas. Arraste a borda dela, ou use as setas quando a borda estiver selecionada, para mudar a largura. Ela abre do jeito que você a deixou.', + ], +]; +const section = ([title, text]: string[]) => `

    ${title}

    ${text}

    `; export function showHelp() { modal( - `

    Bem-vindo ao Fólio

    ${ib('close-help', 'Fechar guia', 'close', 'data-close')}

    Um lugar tranquilo para seus PDFs, inteiramente no seu computador.

    1. Traga suas leituras

    Escolha um ou vários PDFs, ou arraste-os para a janela. Cada arquivo é copiado para a biblioteca. O original nunca é alterado.

    2. Leia no seu ritmo

    A página, a posição e o zoom são salvos automaticamente. Nas preferências, diminua o brilho da leitura sem inverter as cores. Combine com tela quente e qualquer cor de página. Ative Kindle no topo do leitor para passar por telas no zoom atual, com uma pequena sobreposição. O texto mantém a diagramação original; páginas digitalizadas também funcionam. Em zoom muito alto, use a barra horizontal ou Shift + roda para ver as laterais.

    3. Guarde o que importa

    Favorite documentos, marque páginas e anote suas ideias. Notas e marcadores ficam na biblioteca, separados do PDF.

    ${[ - ['Ctrl + O', 'Adicionar PDFs'], - ['Ctrl + K', 'Buscar na biblioteca'], - ['Ctrl + F', 'Buscar no PDF'], - ['Ctrl + B', 'Marcar página'], - ['Ctrl + + / −', 'Ajustar zoom'], - ['K', 'Alternar modo Kindle'], - ['← / → · Page Up / Down', 'Voltar / avançar tela no Kindle'], - ['Espaço / Shift + Espaço', 'Avançar / voltar tela no Kindle'], - ['Home / End', 'Início / fim no Kindle'], - ['D', 'Alternar papel escuro'], - ['R', 'Girar páginas'], - ['F8 / F11', 'Foco / tela cheia'], - ['Alt + ←', 'Voltar à biblioteca'], - ] - .map(([k, v]) => `
    ${v}${k}
    `) - .join( - '', - )}

    A busca encontra texto existente no PDF; documentos digitalizados sem texto precisam de OCR externo. Para backup, copie a pasta inteira da biblioteca, incluindo o catálogo. PDFs removidos podem ser restaurados em “Removidos”.

    `, + `

    Guia e atalhos

    ${ib('close-help', 'Fechar guia', 'close', 'data-close')}
    ${TOPICS.map(section).join('')}

    Atalhos

    As teclas de uma letra (K, L, S, D, R, H e N) e o “?” podem ser desligados em Preferências, em “Atalhos de uma tecla”. F1 abre este guia sempre; no leitor, ele também está em “Mais opções”.

    ${SHORTCUTS.map(([k, v]) => `
    ${v}
    ${k}
    `).join('')}
    ${DETAILS.map(section).join('')}

    A busca encontra texto existente no PDF; documentos digitalizados sem texto precisam de OCR externo. Para backup, copie a pasta inteira da biblioteca, incluindo o catálogo. PDFs removidos podem ser restaurados em “Removidos”, onde também podem ser excluídos de vez para liberar espaço: as cópias são excluídas, o original não é afetado e o histórico de leitura é mantido.

    `, ); } diff --git a/src/ui/history-dialog.ts b/src/ui/history-dialog.ts new file mode 100644 index 0000000..ff3a836 --- /dev/null +++ b/src/ui/history-dialog.ts @@ -0,0 +1,166 @@ +import type { Book, Preferences } from '../types'; +import { dayCount, localDay } from '../reading/goals'; +import { + calendarStep, + firstHistoryDay, + historyDay, + monthDays, + statusLabels, +} from '../reading/history'; +import { icon } from '../icons'; +import { $, esc, ib } from './dom'; +import { modal } from './overlays'; + +// `archived` has the ids of the books that were deleted for good: they stay in the list for the days +// they have, and say so when another book has the same title. `back` is there when the history was +// opened from the goals: it takes the reader back to them. +export function showHistory( + prefs: Preferences, + books: Book[], + initial?: string, + archived: ReadonlySet = new Set(), + back?: () => void, +) { + let scope = initial || 'global', + selected = localDay(), + month = selected.slice(0, 7), + drawn = false; + const date = (day: string) => new Date(`${day}T12:00:00`); + const optionLabel = (b: Book) => + archived.has(b.id) && books.some((o) => o.id !== b.id && o.title === b.title) + ? `${b.title} (excluído)` + : b.title; + const quantity = (count: number, unit: 'página' | 'capítulo') => + `${count} ${unit}${count === 1 ? '' : 's'}`; + const label = (day: string) => + date(day).toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' }); + const monthName = (value: string) => + date(`${value}-01`).toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); + const render = () => { + const today = localDay(), + first = firstHistoryDay(books, prefs, today); + const days = monthDays(month), + elapsed = days.filter((day) => day <= today); + const data = elapsed.map((day) => historyDay(books, prefs, day, scope, today)); + const summary = data.reduce( + (a, d) => ({ + pages: a.pages + d.pages, + chapters: a.chapters + d.chapters, + met: a.met + Number(d.status === 'met'), + }), + { pages: 0, chapters: 0, met: 0 }, + ); + const detail = historyDay(books, prefs, selected, scope, today); + const target = detail.goal + ? `${detail.own ? 'Meta própria do documento' : 'Meta geral'} · ${detail.count} / ${detail.goal.target} ${detail.goal.unit === 'pages' ? 'páginas' : 'capítulos'}` + : detail.status === 'none' + ? 'Não havia meta geral neste dia.' + : detail.status === 'no-own' + ? 'Este documento não tinha meta própria neste dia. A leitura dele entra na meta geral.' + : 'A meta deste dia não foi registrada. Os totais disponíveis foram preservados.'; + const contributions = detail.books.filter( + (b) => dayCount(b, 'pages', selected) > 0 || dayCount(b, 'chapters', selected) > 0, + ); + const previous = localDay( + new Date(date(month + '-01').getFullYear(), date(month + '-01').getMonth() - 1, 1, 12), + ).slice(0, 7); + const next = localDay( + new Date(date(month + '-01').getFullYear(), date(month + '-01').getMonth() + 1, 1, 12), + ).slice(0, 7); + modal( + `

    Histórico de leitura

    ${back ? `` : ''}${ib('history-close', 'Fechar histórico', 'close', 'data-close')}
    +
    +
    ${ib('history-prev', 'Mês anterior', 'left', previous < first.slice(0, 7) ? 'disabled' : '')}${ib('history-next', 'Próximo mês', 'right', next > today.slice(0, 7) ? 'disabled' : '')}
    +

    +

    ${scope === 'global' ? 'Os totais incluem todos os documentos. A meta geral conta toda a leitura do dia, em qualquer documento.' : 'Os totais são deste documento. Com meta própria no dia, o resultado usa a meta dele. Sem meta própria, só os totais aparecem, e a leitura continua contando na meta geral.'}

    +
    ${summary.pages}páginas no mês
    ${summary.chapters}capítulos no mês
    ${summary.met}dias com meta atingida
    + +

    Use as setas para andar pelos dias, Home e End para ir ao começo e ao fim da semana, Page Up e Page Down para trocar de mês, e Enter ou Espaço para escolher o dia.

    +
    ${''.repeat((date(days[0]).getDay() + 6) % 7)}${days + .map((day) => { + const d = historyDay(books, prefs, day, scope, today), + future = day > today; + const state = !d.recorded ? 'Sem registro' : statusLabels[d.status]; + return ``; + }) + .join('')}
    +

    ${esc(label(selected))}

    ${detail.recorded ? statusLabels[detail.status] : 'Sem registro'}

    ${esc(target)}

    ${quantity(detail.pages, 'página')}${quantity(detail.chapters, 'capítulo')}
    + ${contributions.length ? `
      ${contributions.map((b) => `
    • ${esc(b.title)}${dayCount(b, 'pages', selected)} pág. · ${dayCount(b, 'chapters', selected)} cap.
    • `).join('')}
    ` : `

    ${detail.recorded ? 'Nenhuma leitura registrada neste dia.' : 'Ainda não há dados para este dia.'}

    `}
    +

    A meta que valia em cada dia é a última salva naquele dia. Alterações posteriores não mudam a meta de dias anteriores. Os totais incluem ajustes manuais. Dias antigos sem registro não são reconstruídos.

    `, + drawn, + ); + drawn = true; + if (back) $('#history-back').onclick = back; + $('#history-scope').value = scope; + $('#history-scope').onchange = () => { + scope = $('#history-scope').value; + render(); + }; + // Not back to the old month in silence: the field says which months there are. + const refuseMonth = () => { + $('#history-month').value = month; + $('#history-hint').textContent = + first.slice(0, 7) === today.slice(0, 7) + ? `Só há registro de ${monthName(today.slice(0, 7))}.` + : `Escolha um mês entre ${monthName(first.slice(0, 7))} e ${monthName(today.slice(0, 7))}.`; + }; + const changeMonth = (value: string) => { + if ( + !/^\d{4}-(0[1-9]|1[0-2])$/.test(value) || + value < first.slice(0, 7) || + value > today.slice(0, 7) + ) + return refuseMonth(); + month = value; + selected = month === today.slice(0, 7) ? today : monthDays(month).at(-1)!; + render(); + }; + $('#history-month').onchange = () => changeMonth($('#history-month').value); + $('#history-prev').onclick = () => changeMonth(previous); + $('#history-next').onclick = () => changeMonth(next); + document.querySelectorAll('[data-history-day]').forEach((button) => { + button.onclick = () => { + selected = button.dataset.historyDay!; + render(); + document + .querySelector(`[data-history-day="${selected}"]`) + ?.focus({ preventScroll: true }); + $('.history-detail').scrollIntoView({ block: 'nearest' }); + }; + }); + // The days are one stop of Tab (the selected one, or the last that the arrows reached) and the + // keys move the focus among them. Enter and Space are the buttons' own: they select the day. + // A key that leaves the month brings the new one, with the day chosen in it; one that would + // go to a day that cannot be shown says why, in the hint, and stays. + const calendar = $('.history-calendar'); + calendar.onkeydown = (e) => { + const from = (e.target as HTMLElement).closest('[data-history-day]'); + if (!from || e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return; + const step = calendarStep(from.dataset.historyDay!, e.key, today, first); + if (!step) return; + e.preventDefault(); + if ('refused' in step) { + if (step.refused === 'future' && !e.key.startsWith('Page')) + $('#history-hint').textContent = 'Esse dia ainda não chegou.'; + else refuseMonth(); + return; + } + if (step.day.slice(0, 7) !== month) { + month = step.day.slice(0, 7); + selected = step.day; + render(); + document + .querySelector(`[data-history-day="${step.day}"]`) + ?.focus({ preventScroll: true }); + return; + } + $('#history-hint').textContent = ''; + calendar.querySelector('[data-history-day][tabindex="0"]')?.setAttribute('tabindex', '-1'); + const next = calendar.querySelector(`[data-history-day="${step.day}"]`); + if (!next) return; + next.tabIndex = 0; + next.focus(); + }; + }; + render(); +} diff --git a/src/ui/import-progress.ts b/src/ui/import-progress.ts new file mode 100644 index 0000000..6e9fa21 --- /dev/null +++ b/src/ui/import-progress.ts @@ -0,0 +1,42 @@ +import { $ } from './dom'; + +// The progress of an import lives in the button that started it, and not in a message for every +// file: "Copiando 3 de 10…", a thin bar, and a polite region for screen readers. +export const IMPORT_LABEL = 'Adicionar PDFs'; + +export function importLabel(done: number, total: number): string { + return `Copiando ${done} de ${total}…`; +} +// The part of the bar that is full, from 0 to 1. +export function importFraction(done: number, total: number): number { + return total > 0 ? Math.min(1, Math.max(0, done / total)) : 0; +} +// What the screen reader is told. Not every file: the first, and then a few times along the way. +export function importAnnouncement(done: number, total: number): string | null { + const step = Math.max(1, Math.ceil(total / 5)); + return done === 0 || (done < total && done % step === 0) ? importLabel(done, total) : null; +} + +export function showImportProgress(done: number, total: number) { + const button = $('#import-main'); + const label = importLabel(done, total); + button.setAttribute('disabled', ''); + button.setAttribute('aria-busy', 'true'); + $('#import-folder').setAttribute('disabled', ''); + $('#import-main .add-label').textContent = label; + $('#import-main .add-bar').hidden = false; + button.style.setProperty('--import-progress', String(importFraction(done, total))); + const said = importAnnouncement(done, total); + if (said) $('#import-status').textContent = said; +} + +export function hideImportProgress() { + const button = $('#import-main'); + button.removeAttribute('disabled'); + button.removeAttribute('aria-busy'); + $('#import-folder').removeAttribute('disabled'); + $('#import-main .add-label').textContent = IMPORT_LABEL; + $('#import-main .add-bar').hidden = true; + button.style.removeProperty('--import-progress'); + $('#import-status').textContent = ''; +} diff --git a/src/ui/jump-pill.ts b/src/ui/jump-pill.ts new file mode 100644 index 0000000..98c474b --- /dev/null +++ b/src/ui/jump-pill.ts @@ -0,0 +1,91 @@ +import { $ } from './dom'; +import type { JumpOffer } from '../reading/jumps'; + +// How long the pill stays after a jump, and how long after the pointer or the focus lets go of it. +const HIDE_AFTER = 15000, + GRACE = 6000; + +// The pill over the reading surface that offers the way back after a jump ("Voltar à pág. 12"), +// or the way forward again after going back. It floats: showing or hiding it never moves the PDF. +// It leaves after a while, when it is used or dismissed, and when the book is closed; the pointer +// or the focus on it stops the clock. It shows one side at a time, the forward one in a quieter +// colour. The keys are the reader's own (Alt + ← and Alt + →), and a polite message says so. +export class JumpPill { + private timer: ReturnType | undefined; + private hover = false; + private focus = false; + + constructor(actions: { back: () => void; forward: () => void }) { + const pill = $('#jump-pill'); + $('#jump-back').onclick = actions.back; + $('#jump-forward').onclick = actions.forward; + $('#jump-dismiss').onclick = () => this.hide(); + pill.addEventListener('pointerenter', () => { + this.hover = true; + clearTimeout(this.timer); + }); + pill.addEventListener('pointerleave', () => { + this.hover = false; + this.arm(GRACE); + }); + pill.addEventListener('focusin', () => { + this.focus = true; + clearTimeout(this.timer); + }); + pill.addEventListener('focusout', (e) => { + if (pill.contains(e.relatedTarget as Node | null)) return; + this.focus = false; + this.arm(GRACE); + }); + // Escape closes the pill that has the focus, and only that. + pill.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + this.hide(); + }); + } + + show(offer: JumpOffer) { + const page = offer[offer.show]; + if (page === null) return this.hide(); + const pill = $('#jump-pill'), + back = offer.show === 'back'; + const text = back ? `Voltar à pág. ${page}` : `Avançar à pág. ${page}`, + label = `${text} (${back ? 'Alt + ←' : 'Alt + →'})`, + shown = $(back ? '#jump-back' : '#jump-forward'), + other = $(back ? '#jump-forward' : '#jump-back'); + // The side that goes away must not take the focus with it: the reader gets it back. + const focused = other.contains(document.activeElement); + shown.hidden = false; + other.hidden = true; + $(back ? '#jump-back-text' : '#jump-forward-text').textContent = text; + shown.title = label; + shown.setAttribute('aria-label', label); + pill.dataset.side = offer.show; + pill.hidden = false; + $('#jump-status').textContent = back + ? `Para voltar à página ${page}, use Alt + seta para a esquerda.` + : `Para avançar à página ${page}, use Alt + seta para a direita.`; + if (focused) $('#viewer-container').focus({ preventScroll: true }); + this.arm(HIDE_AFTER); + } + + hide() { + clearTimeout(this.timer); + this.hover = this.focus = false; + const pill = $('#jump-pill'); + if (pill.hidden) return; + const focused = pill.contains(document.activeElement); + pill.hidden = true; + $('#jump-status').textContent = ''; + // A button that goes away must not take the focus with it. + if (focused) $('#viewer-container').focus({ preventScroll: true }); + } + + private arm(ms: number) { + clearTimeout(this.timer); + if (this.hover || this.focus || $('#jump-pill').hidden) return; + this.timer = setTimeout(() => this.hide(), ms); + } +} diff --git a/src/ui/library-view.ts b/src/ui/library-view.ts index 8626d9e..58ce581 100644 --- a/src/ui/library-view.ts +++ b/src/ui/library-view.ts @@ -1,51 +1,226 @@ import type { Book, Snapshot, Preferences } from '../types'; import { asset } from '../bridge'; import { icon } from '../icons'; -import { $, esc, size } from './dom'; -type LibraryViewState = { snapshot: Snapshot; filter: string; query: string; prefs: Preferences }; +import { readingProgress } from '../reading/progress'; +import { formatDuration, remainingTime, secondsPerPage } from '../reading/pace'; +import { searchBook, type SearchHit } from '../library/search'; +import { cardAfterKey } from '../library/card-navigation'; +import { pruneSelection } from '../library/selection'; +import { filterTag, hasTag, tagCounts, tagFilter, tagsOf, type TagCount } from '../library/tags'; +import { $, documents, esc, size } from './dom'; +import type { PopoverOptions } from './overlays'; +type LibraryViewState = { + snapshot: Snapshot; + filter: string; + query: string; + prefs: Preferences; + // The ids ticked. A drawing takes out of it what it does not show. + selected: Set; +}; type LibraryViewActions = { openBook: (book: Book) => void; importFiles: () => void; + importFolder: () => void; toggleFavorite: (id: string) => void; - bookMenu: (anchor: HTMLElement, book: Book) => void; + // Ctrl + click (or the box of the card) ticks or unticks one; Shift + click ticks the range up to it. + select: (id: string, mode: 'toggle' | 'range') => void; + bookMenu: ( + anchor: HTMLElement, + book: Book, + options?: Pick, + ) => void; restoreBook: (id: string) => void; applyPrefs: () => void; + // The way out of a search that found nothing: clears the field and draws the list again. + clearSearch: () => void; +}; +function cover(b: Book, folder: string) { + return `
    ${b.cover ? `` : `
    PDF
    ${icon('file')}
    `}
    `; +} +function progressBar(b: Book) { + return `
    `; +} +// The first tags of a book, on one line that is cut where the card ends. +function tagLine(tags: string[]) { + const shown = tags.slice(0, 3), + more = tags.length - shown.length; + return `

    ${shown.map(esc).join(' · ')}${more ? ` · +${more}` : ''}

    `; +} +// The ids of the author and of the state of a card (where the reading is). The button of the card +// is named "Ler {título}"; these two are what a screen reader says after the name (aria-describedby), +// so that the progress, "Concluído" or "Ainda não lido" are heard and not only seen. +const authorId = (b: Book) => `book-author-${b.id}`; +const stateId = (b: Book) => `book-state-${b.id}`; +const describedBy = (b: Book) => (b.author ? `${authorId(b)} ${stateId(b)}` : stateId(b)); +// Title, author (when the PDF has one), tags, size and where the reading is. It is the part of a +// card that changes when the library queue learns something new about the book. `hit` says where +// a search found it: a search that only the notes answered says so. +function bookInfo(b: Book, hit: SearchHit | null = null) { + const tags = tagsOf(b); + return `

    ${esc(b.title)}

    ${b.author ? `

    ${esc(b.author)}

    ` : ''}${tags.length ? tagLine(tags) : ''}${hit === 'notes' ? `

    ${icon('note')} Encontrado nas notas

    ` : ''}

    ${b.pages ? `${b.pages} página${b.pages === 1 ? '' : 's'} · ` : ''}${size(b.size)}

    ${b.finished ? `${icon('check')} Concluído${progressBar(b)}` : b.lastOpened ? `Página ${b.page}${b.pages ? ` de ${b.pages} · ${readingProgress(b)}%` : ''}${progressBar(b)}` : 'Ainda não lido'}
    `; +} +// The cover of a card with its text. It opens the book; in Removidos it is only a block of text +// (a button that cannot be pressed would be announced as unavailable and dimmed), and the click goes +// through to the card, which can still be ticked. +const cardCover = (b: Book, removed: boolean, content: string) => + removed + ? `
    ${content}
    ` + : ``; +// The star of a card, as the one of the reader: the name stays ("Favoritar {título}") and `aria-pressed` +// says the state. The tooltip says what a click does now. +const favoriteLabel = (b: Book) => `Favoritar ${b.title}`; +const favoriteTitle = (b: Book) => (b.favorite ? 'Remover dos favoritos' : 'Favoritar'); +const cardOf = (id: string) => + $('#books').querySelector(`[data-book="${CSS.escape(id)}"]`); +// One tag of the side bar. The list is drawn again only when a tag, a spelling or a count +// changed, so that a click on a tag does not take the focus away from its button. +function renderTagNav(known: TagCount[]) { + $('#tag-section').hidden = !known.length; + const nav = $('#tag-nav'); + const signature = known.map((t) => `${t.key}\u0000${t.label}\u0000${t.count}`).join('\u0001'); + if (nav.dataset.signature === signature) return; + nav.dataset.signature = signature; + const focused = nav.contains(document.activeElement) + ? (document.activeElement as HTMLElement).dataset.filter + : null; + nav.innerHTML = known + .map( + (t) => + ``, + ) + .join(''); + if (focused) + nav + .querySelector(`[data-filter="${CSS.escape(focused)}"]`) + ?.focus({ preventScroll: true }); +} +// The cards of the library are one stop of Tab (the roving tabindex of the WAI-ARIA Authoring +// Practices): the buttons of the active card (open, star, options; in Removidos, restore and +// options) are in the order of Tab, and those of the others are not. The arrows, Home and End move +// the focus from card to card, and the card that has the focus is the active one. The box that ticks +// a card is out of the order, and Space on the main button ticks it. +const CARD_BUTTONS = '[data-open], [data-restore], [data-favorite], [data-menu]'; +const mainButton = (card: HTMLElement) => + card.querySelector('[data-open], [data-restore]'); +let activeBook: string | null = null; +function activateCard(card: HTMLElement | null) { + const previous = activeBook ? cardOf(activeBook) : null; + if (previous && previous !== card) + previous.querySelectorAll(CARD_BUTTONS).forEach((b) => (b.tabIndex = -1)); + activeBook = card?.dataset.book ?? null; + card?.querySelectorAll(CARD_BUTTONS).forEach((b) => (b.tabIndex = 0)); +} +// The main button of the card that is the stop of Tab. +const activeButton = () => { + const card = activeBook ? cardOf(activeBook) : null; + return (card && mainButton(card)) ?? mainButton($('#books')); +}; +let boundGrid: HTMLElement | null = null; +// A toggle of the selection bar keeps its name and its text; the state is `aria-pressed` (and the look +// that goes with it), and the tooltip says what the click does now. +const setToggle = (button: HTMLElement, pressed: boolean, action: string) => { + button.setAttribute('aria-pressed', String(pressed)); + button.title = action; }; -function cover(b: Book, folder: string, small = false) { - return `
    ${b.cover ? `` : `
    PDF
    ${icon('file')}
    `}
    `; +// The bar of the selection, and the ticks of the cards, from `selected`. It does not draw the list +// again: a click on a card only changes these. +export function updateSelection(state: Pick) { + const { snapshot, filter, selected } = state; + const trash = filter === 'trash'; + const grid = $('#books'); + grid.classList.toggle('selecting', selected.size > 0); + grid.querySelectorAll('.book-card').forEach((card) => { + const on = selected.has(card.dataset.book!); + card.classList.toggle('selected', on); + const box = card.querySelector('[data-select]'); + if (box) box.checked = on; + }); + const chosen = (trash ? snapshot.trash : snapshot.books).filter((b) => selected.has(b.id)); + const bar = $('#selection-bar'); + const leaving = !bar.hidden && !chosen.length && bar.contains(document.activeElement); + bar.hidden = !chosen.length; + document.documentElement.toggleAttribute('data-selecting', !!chosen.length); + $('#selection-count').textContent = chosen.length + ? `${chosen.length} selecionado${chosen.length === 1 ? '' : 's'}` + : ''; + for (const id of ['favorite', 'finish', 'tag', 'remove']) $(`#select-${id}`).hidden = trash; + for (const id of ['restore', 'purge']) $(`#select-${id}`).hidden = !trash; + if (chosen.length && !trash) { + const favorite = chosen.every((b) => b.favorite), + finished = chosen.every((b) => b.finished); + setToggle($('#select-favorite'), favorite, favorite ? 'Remover dos favoritos' : 'Favoritar'); + setToggle( + $('#select-finish'), + finished, + finished ? 'Marcar como não concluído' : 'Marcar como concluído', + ); + } + // A bar that goes away must not take the focus with it. + if (leaving) (activeButton() ?? $('#library-search')).focus({ preventScroll: true }); } +// Draws the library: the side bar's tags and counts, the title of the filter and the cards. Says +// which cards are on screen, in order. export function renderLibraryView(state: LibraryViewState, actions: LibraryViewActions) { - const { snapshot, filter, query, prefs } = state; - const { openBook, importFiles, toggleFavorite, bookMenu, restoreBook, applyPrefs } = actions; + const { snapshot, filter, query, prefs, selected } = state; + const { + openBook, + importFiles, + importFolder, + toggleFavorite, + bookMenu, + restoreBook, + applyPrefs, + clearSearch, + } = actions; const all = snapshot.books; + const known = tagCounts(all); const counts = { all: all.length, reading: all.filter((b) => b.lastOpened && !b.finished).length, favorites: all.filter((b) => b.favorite).length, finished: all.filter((b) => b.finished).length, + trash: snapshot.trash.length, }; Object.entries(counts).forEach(([key, v]) => ($(`#count-${key}`).textContent = String(v))); - document - .querySelectorAll('[data-filter]') - .forEach((b) => b.classList.toggle('active', b.getAttribute('data-filter') === filter)); - const labels: Record = { - all: ['Sua biblioteca', 'Boas leituras começam por aqui.'], - reading: ['Em leitura', 'Seu próximo capítulo está aqui.'], - favorites: ['Seus favoritos', 'As leituras que merecem ficar por perto.'], - finished: ['Leituras concluídas', 'Cada página, uma nova perspectiva.'], - trash: ['Removidos', 'Restaure um documento quando quiser. As cópias são preservadas.'], + renderTagNav(known); + document.querySelectorAll('[data-filter]').forEach((b) => { + const active = b.getAttribute('data-filter') === filter; + b.classList.toggle('active', active); + if (active) b.setAttribute('aria-current', 'true'); + else b.removeAttribute('aria-current'); + }); + const tag = filterTag(filter); + // The title is the name of the filter, as the side bar says it. Only Removidos has a line under it, + // and it says what is not obvious there: the copies stay until they are deleted. + const names: Record = { + all: 'Todos os documentos', + reading: 'Em leitura', + favorites: 'Favoritos', + finished: 'Concluídos', + trash: 'Removidos', }; - $('#library-title').textContent = labels[filter][0]; - $('#library-subtitle').textContent = labels[filter][1]; - $('#library-total').textContent = - `${all.length} ${all.length === 1 ? 'documento' : 'documentos'} · ${size(all.reduce((v, b) => v + b.size, 0))}`; - let books = (filter === 'trash' ? snapshot.trash : all).filter( - (b) => - (filter !== 'reading' || (b.lastOpened && !b.finished)) && - (filter !== 'favorites' || b.favorite) && - (filter !== 'finished' || b.finished) && - `${b.title} ${b.originalName}`.toLocaleLowerCase().includes(query.toLocaleLowerCase()), - ); + const tagged = known.find((t) => t.key === tag); + const title = tagged ? `Etiqueta: ${tagged.label}` : (names[filter] ?? names.all); + $('#library-title').textContent = title; + $('#library-subtitle').textContent = + filter === 'trash' ? 'As cópias ficam guardadas até você excluí-las.' : ''; + $('#library-subtitle').hidden = filter !== 'trash'; + // The goal is for reading: it stays out of Removidos, of a search and of the filters that are not + // the reading itself. + $('#library-goal').hidden = (filter !== 'all' && filter !== 'reading') || !!query; + $('#trash-bar').hidden = filter !== 'trash' || !snapshot.trash.length; + $('#trash-summary').textContent = + `${documents(snapshot.trash.length)} · ${size(snapshot.trash.reduce((v, b) => v + b.size, 0))} que podem ser liberados`; + const hits = new Map(); + const books = (filter === 'trash' ? snapshot.trash : all).filter((b) => { + if (filter === 'reading' && !(b.lastOpened && !b.finished)) return false; + if (filter === 'favorites' && !b.favorite) return false; + if (filter === 'finished' && !b.finished) return false; + if (tag !== null && !hasTag(b, tag)) return false; + const hit = searchBook(b, query); + if (hit) hits.set(b.id, hit); + return !!hit; + }); books.sort((a, b) => prefs.sort === 'title' ? a.title.localeCompare(b.title, 'pt-BR') @@ -55,49 +230,241 @@ export function renderLibraryView(state: LibraryViewState, actions: LibraryViewA ? b.added - a.added : (b.lastOpened || b.added) - (a.lastOpened || a.added), ); + // What is not on screen is not selected: the bar counts what its buttons act on. + pruneSelection( + selected, + books.map((b) => b.id), + ); + // The count of what the list shows, at the left of its toolbar: a search says how many it found. + // Without a search it stays out of an empty list (the message says so) and of Removidos (the bar + // under the title already counts what is there). + const total = $('#library-total'); + total.textContent = query + ? `${books.length} resultado${books.length === 1 ? '' : 's'}` + : `${documents(books.length)} · ${size(books.reduce((v, b) => v + b.size, 0))}`; + total.hidden = !query && (filter === 'trash' || !books.length); const recent = [...all] .filter((b) => b.lastOpened && !b.finished) .sort((a, b) => b.lastOpened - a.lastOpened)[0]; const continuation = $('#continue'); continuation.hidden = filter !== 'all' || !!query || !recent; if (recent && !continuation.hidden) { - const percent = recent.pages ? Math.round((recent.page / recent.pages) * 100) : 0; - continuation.innerHTML = `
    ${icon('book')}
    UM BOM PONTO PARA RECOMEÇAR

    ${esc(recent.title)}

    Página ${recent.page} de ${recent.pages} · ${percent}% da leitura
    `; - $('#continue-read').onclick = () => openBook(recent); + continuation.dataset.book = recent.id; + const percent = readingProgress(recent); + const left = remainingTime({ + page: recent.page, + pages: recent.pages, + secondsPerPage: secondsPerPage(prefs.pace, recent.id), + }); + // One line: the cover, the title with where the reading is, the gauge and the way in. The click + // is anywhere on the strip; the button is the one that the keyboard and a screen reader reach. + continuation.innerHTML = `${cover(recent, snapshot.folder)}

    ${esc(recent.title)}

    Página ${recent.page}${recent.pages ? ` de ${recent.pages}` : ''} · ${percent}%${left ? ` · ≈ ${formatDuration(left.book)} para terminar` : ''}
    `; + // The book as it is when the strip is pressed: the queue may have learned more since. + continuation.onclick = () => openBook(snapshot.books.find((x) => x.id === recent.id) ?? recent); } - $('#collection-label').textContent = query - ? `${books.length} RESULTADO${books.length === 1 ? '' : 'S'}` - : filter === 'all' - ? 'TODOS OS DOCUMENTOS' - : labels[filter][0].toLocaleUpperCase(); const grid = $('#books'); grid.className = prefs.view === 'list' ? 'book-list' : 'book-grid'; grid.innerHTML = books .map( (b) => - `
    ${filter === 'trash' ? `` : ``}
    `, + `
    ${cardCover(b, filter === 'trash', `${cover(b, snapshot.folder)}${bookInfo(b, hits.get(b.id))}`)}
    ${filter === 'trash' ? `` : ``}
    `, ) .join(''); $('#empty').innerHTML = books.length ? '' - : `
    ${icon(query ? 'search' : 'book')}
    ${query ? 'TENTE OUTRO TERMO' : 'SUA PRÓXIMA LEITURA COMEÇA AQUI'}

    ${query ? 'Nenhum PDF encontrado' : filter === 'all' ? 'Um lugar para suas leituras' : filter === 'trash' ? 'Nenhum documento removido' : 'Nenhum documento por aqui'}

    ${query ? 'Busque pelo título ou pelo nome original do arquivo.' : filter === 'all' ? 'Adicione um PDF ou uma coleção inteira.
    O Fólio cuida da biblioteca. Você aproveita a leitura.' : filter === 'favorites' ? 'Marque a estrela de um PDF para encontrá-lo aqui.' : filter === 'reading' ? 'Abra um PDF para acompanhar sua leitura aqui.' : filter === 'finished' ? 'Use “Marcar como concluído” nas opções de um PDF.' : 'Documentos removidos da biblioteca aparecem aqui.'}

    ${filter === 'all' && !query ? `Ou arraste os arquivos para esta janela` : ''}
    `; + : `
    ${icon(query ? 'search' : 'book')}

    ${query ? 'Nenhum documento encontrado' : filter === 'all' ? 'Nenhum documento ainda' : filter === 'trash' ? 'Nenhum documento removido' : 'Nenhum documento por aqui'}

    ${query ? 'Busque pelo título, autor, nome do arquivo, etiquetas ou notas.' : filter === 'all' ? 'Adicione PDFs ou uma pasta, ou arraste para esta janela.' : filter === 'favorites' ? 'Marque a estrela de um documento para encontrá-lo aqui.' : filter === 'reading' ? 'Abra um documento para acompanhar sua leitura aqui.' : filter === 'finished' ? 'Use “Marcar como concluído” nas opções de um documento.' : tag !== null ? 'Adicione esta etiqueta a um documento pelo menu dele.' : 'Documentos removidos da biblioteca aparecem aqui.'}

    ${query ? `` : filter === 'all' ? `` : ''}
    `; $('#import-empty')?.addEventListener('click', () => importFiles()); + $('#import-folder-empty')?.addEventListener('click', () => importFolder()); + $('#clear-search')?.addEventListener('click', () => clearSearch()); + // Ctrl + click ticks a card, Shift + click ticks up to it; with neither, the click is the + // card's own. The keyboard does the same: Ctrl + Enter or Shift + Enter on the card. + const how = (e: MouseEvent) => (e.shiftKey ? 'range' : e.ctrlKey || e.metaKey ? 'toggle' : null); + grid.querySelectorAll('[data-open]').forEach( + (b) => + (b.onclick = (e) => { + const mode = how(e); + if (mode) actions.select(b.dataset.open!, mode); + else openBook(snapshot.books.find((x) => x.id === b.dataset.open)!); + }), + ); grid - .querySelectorAll('[data-open]') - .forEach((b) => (b.onclick = () => openBook(all.find((x) => x.id === b.dataset.open)!))); + .querySelectorAll('[data-select]') + .forEach( + (box) => + (box.onclick = (e) => actions.select(box.dataset.select!, e.shiftKey ? 'range' : 'toggle')), + ); grid .querySelectorAll('[data-favorite]') .forEach((b) => (b.onclick = () => toggleFavorite(b.dataset.favorite!))); - grid.querySelectorAll('[data-menu]').forEach( - (b) => - (b.onclick = () => - bookMenu( - b, - all.find((x) => x.id === b.dataset.menu)!, - )), - ); + const find = (id: string) => [...snapshot.books, ...snapshot.trash].find((x) => x.id === id)!; + grid + .querySelectorAll('[data-menu]') + .forEach((b) => (b.onclick = () => bookMenu(b, find(b.dataset.menu!)))); + // The same menu as the button of the card, for the right button and for the keyboard. A card + // has no focus of its own: the control that has it (or the one that opens the book) gets it back. + const cardAt = (target: EventTarget | null) => + (target as HTMLElement | null)?.closest('.book-card') ?? null; + const control = (card: HTMLElement) => + card.querySelector('[data-open], [data-restore]')!; + const anchorOf = (card: HTMLElement) => + card.querySelector('[data-menu]') ?? control(card); + const bookOf = (card: HTMLElement) => find(card.dataset.book!); + // In Removidos the cover opens nothing: the click is the card's, which can still be ticked. + grid.onclick = (e) => { + const card = cardAt(e.target); + const mode = how(e); + if (card && mode && !(e.target as HTMLElement).closest('button, input')) + actions.select(card.dataset.book!, mode); + }; + grid.oncontextmenu = (e) => { + const card = cardAt(e.target); + if (!card) return; + e.preventDefault(); + if (e.button === 2) + bookMenu(anchorOf(card), bookOf(card), { + at: { x: e.clientX, y: e.clientY }, + owner: control(card), + }); + // A menu key arrives here too, on some systems, after the key down that already opened it. + else if ($('#popover').hidden) bookMenu(anchorOf(card), bookOf(card), { owner: control(card) }); + }; + grid.onkeydown = (e) => { + const card = cardAt(e.target); + if (!card) return; + if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) { + e.preventDefault(); + bookMenu(anchorOf(card), bookOf(card), { owner: e.target as HTMLElement }); + return; + } + if (e.ctrlKey || e.altKey || e.metaKey) return; + // Space on the main button ticks the card, as its box does (Shift + Space ticks the range); + // Enter, which opens, is the button's own. The star and the options keep Space for themselves. + if (e.key === ' ') { + if (!(e.target as HTMLElement).matches('[data-open], [data-restore]')) return; + e.preventDefault(); + if (!e.repeat) actions.select(card.dataset.book!, e.shiftKey ? 'range' : 'toggle'); + return; + } + if ( + e.shiftKey || + !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key) + ) + return; + // The arrows, Home and End, from any button of the card, to the main button of another. + const cards = [...grid.querySelectorAll('.book-card')]; + const boxes = cards.map((c) => c.getBoundingClientRect()); + const at = cardAfterKey( + boxes, + cards.indexOf(card), + e.key, + grid.classList.contains('book-list'), + ); + if (at === undefined) return; + e.preventDefault(); + // The card is brought into view first: a card far from the screen is drawn only when it gets + // near it, and the focus can then go to it. The first row also shows what is above it. + cards[at].scrollIntoView({ block: 'nearest' }); + if (boxes[at].top - boxes[0].top < 4) grid.closest('.library-main')?.scrollTo({ top: 0 }); + mainButton(cards[at])?.focus({ preventScroll: true }); + }; + // Whatever gives focus to a button of a card (a key, a click, the drawing that puts it back) makes + // that card the one of Tab. + if (boundGrid !== grid) { + boundGrid = grid; + grid.addEventListener('focusin', (e) => { + const card = cardAt(e.target); + if (card) activateCard(card); + }); + } grid .querySelectorAll('[data-restore]') .forEach((b) => (b.onclick = () => restoreBook(b.dataset.restore!))); + // The card that was the stop of Tab stays it, if it is still on screen; else the first. + activateCard( + (activeBook ? cardOf(activeBook) : null) ?? grid.querySelector('.book-card'), + ); + updateSelection(state); applyPrefs(); + return books.map((b) => b.id); +} + +// Draws one card again after the library queue gave its book a cover, a page count, an author or +// a title. Only the parts that changed are replaced, so the scroll and the focus stay where they +// are, and an image that was loading does not blink. Says whether it could: when the new text +// moves the card (the "Título A–Z" order) or decides whether it is shown (a search), the whole list +// must be drawn again, and the caller does that. +export function updateBookCard(state: LibraryViewState, book: Book, before: Book): boolean { + const { snapshot, query, prefs } = state; + const retitled = book.title !== before.title; + if ( + (retitled && prefs.sort === 'title') || + (query && (retitled || book.author !== before.author)) + ) + return false; + const card = cardOf(book.id); + if (card) { + card.querySelector('.book-cover')?.replaceWith(htmlOf(cover(book, snapshot.folder))); + card + .querySelector('.book-info') + ?.replaceWith(htmlOf(bookInfo(book, query ? searchBook(book, query) : null))); + // A button keeps its place; only what a screen reader says about it changes. The author may be + // new, and with it the second thing that the button is described by. + card.querySelector('[data-open]')?.setAttribute('aria-label', `Ler ${book.title}`); + card.querySelector('[data-open]')?.setAttribute('aria-describedby', describedBy(book)); + card.querySelector('[data-select]')?.setAttribute('aria-label', `Selecionar ${book.title}`); + card.querySelector('[data-favorite]')?.setAttribute('aria-label', favoriteLabel(book)); + card.querySelector('[data-menu]')?.setAttribute('aria-label', `Opções de ${book.title}`); + } + // The strip shows the cover and the title of the book that it offers. + const continuation = $('#continue'); + if (!continuation.hidden && continuation.dataset.book === book.id) { + continuation.querySelector('.book-cover')?.replaceWith(htmlOf(cover(book, snapshot.folder))); + const title = continuation.querySelector('h2')!; + title.textContent = book.title; + title.title = book.title; + $('#continue-read').setAttribute('aria-label', `Continuar leitura de ${book.title}`); + } + return true; +} +function htmlOf(html: string): Element { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content.firstElementChild!; +} + +// Takes the reader to a card: scrolls to it, gives it the focus and marks it for a moment. +// Says whether the card is on screen (it is not under a search or a filter that hides it). +export function revealBook(id: string): boolean { + const card = cardOf(id); + if (!card) return false; + const bring = () => card.scrollIntoView({ block: 'center' }); + bring(); + card.querySelector('[data-open]')?.focus({ preventScroll: true }); + // Cards far from the screen are drawn only when they get near it, and their real height is + // known then: once more, after that. + requestAnimationFrame(bring); + card.classList.add('revealed'); + setTimeout(() => card.classList.remove('revealed'), 2200); + return true; +} + +// Runs a drawing of the library and leaves things as they were: the scroll, and the focus when +// it was on a button of a card or of the library's own messages. +export function keepLibraryView(render: () => void) { + const scroller = document.querySelector('.library-main'); + const top = scroller?.scrollTop ?? 0; + const active = document.activeElement; + let again = ''; + if (active instanceof HTMLElement) { + const card = active.closest('.book-card'); + const control = ['data-open', 'data-favorite', 'data-menu', 'data-restore', 'data-select'].find( + (a) => active.hasAttribute(a), + ); + if (card && control) again = `[data-book="${CSS.escape(card.dataset.book!)}"] [${control}]`; + else if (active.id && ($('#continue').contains(active) || $('#empty').contains(active))) + again = `#${active.id}`; + } + render(); + if (scroller) scroller.scrollTop = top; + if (again) document.querySelector(again)?.focus({ preventScroll: true }); } diff --git a/src/ui/menu-navigation.ts b/src/ui/menu-navigation.ts new file mode 100644 index 0000000..28b9598 --- /dev/null +++ b/src/ui/menu-navigation.ts @@ -0,0 +1,60 @@ +// Moving through the items of a menu, as numbers and text only. `enabled[i]` says whether +// item i can take the focus; `current` is the focused item, or -1 when none is. + +// The item that Arrow Up, Arrow Down, Home or End goes to. The arrows wrap around. +// Null when the key does nothing here, or when there is no item to go to. +export function menuIndexAfterKey(key: string, current: number, enabled: boolean[]): number | null { + const usable = enabled.flatMap((on, i) => (on ? [i] : [])); + if (!usable.length) return null; + if (key === 'Home') return usable[0]; + if (key === 'End') return usable[usable.length - 1]; + if (key !== 'ArrowDown' && key !== 'ArrowUp') return null; + const at = usable.indexOf(current); + if (key === 'ArrowDown') return usable[at < 0 ? 0 : (at + 1) % usable.length]; + return usable[at < 0 ? usable.length - 1 : (at - 1 + usable.length) % usable.length]; +} + +// Letters compare without case or accents: "o" finds "Ótimo". +const fold = (text: string) => + text + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLocaleLowerCase('pt-BR') + .trim(); + +// A key that types a letter, as opposed to a shortcut or a key that acts on the button. +export function isTypedCharacter(event: { + key: string; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +}): boolean { + return ( + [...event.key].length === 1 && + event.key.trim() !== '' && + !event.ctrlKey && + !event.altKey && + !event.metaKey + ); +} + +// The item for the letters typed so far. One letter (or the same one again) goes to the next +// item that starts with it, so that repeating the key cycles through them. Several different +// letters narrow down from the current item. Null when no item starts with them. +export function typeaheadIndex( + labels: string[], + enabled: boolean[], + current: number, + typed: string, +): number | null { + const text = fold(typed); + if (!text || !labels.length) return null; + const repeated = [...text].every((c) => c === text[0]); + const needle = repeated ? text[0] : text; + const first = repeated ? current + 1 : Math.max(0, current); + for (let step = 0; step < labels.length; step++) { + const i = (((first + step) % labels.length) + labels.length) % labels.length; + if (enabled[i] && fold(labels[i]).startsWith(needle)) return i; + } + return null; +} diff --git a/src/ui/note-menu.ts b/src/ui/note-menu.ts new file mode 100644 index 0000000..403223f --- /dev/null +++ b/src/ui/note-menu.ts @@ -0,0 +1,59 @@ +import { icon } from '../icons'; +import { $ } from './dom'; +import { closePopover, popover, type PopoverOptions } from './overlays'; +import { COLOR_LABELS, NOTE_COLORS, colorOf } from '../reading/note-colors'; +import { isHighlight } from '../reading/note-rects'; +import type { Note, NoteColor } from '../types'; + +export type NoteMenuActions = { + edit: (note: Note) => void; + color: (note: Note, color: NoteColor) => void; + copy: (note: Note) => void; + remove: (note: Note) => void; +}; + +// What the menu of a note says it deletes: the highlight, and the note when it has text. +export function deleteLabel(note: Note) { + if (!isHighlight(note)) return 'Excluir nota'; + return note.text.trim() ? 'Excluir realce e nota' : 'Excluir realce'; +} + +// The options of a note or a highlight, from its mark in the page, from the text itself (at the +// pointer) or from its card in the side bar. The colours are only for a highlight. +export function noteMenu( + anchor: HTMLElement, + note: Note, + actions: NoteMenuActions, + options: Pick = {}, +) { + const highlight = isHighlight(note), + current = colorOf(note); + const color = (value: NoteColor) => { + const name = `${COLOR_LABELS[value]}${value === current ? ' (atual)' : ''}`; + return ``; + }; + popover( + anchor, + `${highlight ? `` : ''}
    `, + { ...options, menu: true, label: highlight ? 'Opções do realce' : 'Opções da nota' }, + ); + $('#popover') + .querySelectorAll('[data-action]') + .forEach( + (el) => + (el.onclick = () => { + // The focus goes back to where the menu came from, so that a dialog returns it there. + closePopover(true); + switch (el.dataset.action) { + case 'edit': + return actions.edit(note); + case 'color': + return actions.color(note, el.dataset.color as NoteColor); + case 'copy': + return actions.copy(note); + case 'remove': + return actions.remove(note); + } + }), + ); +} diff --git a/src/ui/notes-panel.ts b/src/ui/notes-panel.ts new file mode 100644 index 0000000..747a0cf --- /dev/null +++ b/src/ui/notes-panel.ts @@ -0,0 +1,179 @@ +import { icon } from '../icons'; +import { closePopover } from './overlays'; +import { esc } from './dom'; +import { COLOR_LABELS, colorOf } from '../reading/note-colors'; +import { isHighlight, noteOrder } from '../reading/note-rects'; +import type { Note } from '../types'; + +// What the side bar remembers of the Notas tab while a book is open: the text in the search field. +export type NotesState = { query: string }; +export type NotesActions = { + add: () => void; + edit: (note: Note) => void; + // The options of a note, from the button of its card. + menu: (anchor: HTMLElement, note: Note) => void; + // Go to the note: its highlight, or its page when it has none. + go: (note: Note) => void; + exportNotes: () => void; +}; + +// Letters compare without case or accents: "acao" finds "Ação". +const fold = (text: string) => + text + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLocaleLowerCase('pt-BR'); + +// The notes that answer a search, in reading order: those whose text or quote has what was typed. +// A highlight with no text is shown as "Realce", so the word finds it too. +export function matchNotes(notes: readonly Note[], query: string): Note[] { + const ordered = [...notes].sort(noteOrder); + const wanted = fold(query).trim(); + if (!wanted) return ordered; + return ordered.filter((note) => + [ + note.text ?? '', + note.quote ?? '', + isHighlight(note) && !note.text?.trim() ? 'Realce' : '', + ].some((field) => fold(field).includes(wanted)), + ); +} + +// What the tab counts is what its cards show: a highlight with no text is a "Realce", anything +// else is a note. +function countLabel(notes: readonly Note[]) { + const highlights = notes.filter((n) => isHighlight(n) && !(n.text ?? '').trim()).length, + texts = notes.length - highlights; + return [ + texts ? `${texts} ${texts === 1 ? 'nota' : 'notas'}` : '', + highlights ? `${highlights} ${highlights === 1 ? 'realce' : 'realces'}` : '', + ] + .filter(Boolean) + .join(' · '); +} + +function card(note: Note) { + const highlight = isHighlight(note), + color = colorOf(note), + text = (note.text ?? '').trim(); + const goTitle = highlight ? 'Ir ao trecho realçado' : 'Ir à página'; + // The quote is the part of the card that the reader looks for, so it takes to the passage as the + // "Página N" link does. It is a button of its own beside that link, not inside it, and a button + // does not hold another: "Editar nota" and the options stay apart, each with its own focus. + const quote = note.quote + ? `
    ` + : ''; + return `
    ${quote}${text ? `

    ${esc(text)}

    ` : highlight ? '

    Realce

    ' : ''}
    `; +} + +// Where the focus was in the tab. Every change of a note draws the tab again, and the button that had +// the focus goes away with the old drawing; with this the focus is found again in the new one. +export type NotesFocus = { + // The control: a selector, inside the card of `note` or (without a note) in the tab itself. + control: string; + note: string | null; + // The notes next to it, the one after first, for when it is gone (it was deleted). + near: string[]; +}; + +// The place of the focus (`el`) in the tab `root`, or null when it is not in it. +export function notesFocusOf(root: HTMLElement, el: Element | null): NotesFocus | null { + if (!(el instanceof HTMLElement) || !root.contains(el)) return null; + const card = el.closest('.note-card'); + if (!card) return el.id ? { control: `#${CSS.escape(el.id)}`, note: null, near: [] } : null; + const cards = [...root.querySelectorAll('.note-card')], + at = cards.indexOf(card); + const attribute = ['data-go', 'data-go-quote', 'data-more', 'data-edit-note'].find((a) => + el.hasAttribute(a), + ); + return { + control: attribute ? `[${attribute}]` : 'button', + note: card.dataset.noteId ?? null, + near: [cards[at + 1], cards[at - 1]].flatMap((c) => + c?.dataset.noteId ? [c.dataset.noteId] : [], + ), + }; +} + +// Gives the focus back, after the tab was drawn again: to the same control of the same note; if the +// note is gone, to that control of the one next to it; if there is none, to the search, or to the +// button that adds a note when the tab has no search. Says whether there was somewhere to go. +export function restoreNotesFocus(root: HTMLElement, saved: NotesFocus): boolean { + const find = (selector: string) => root.querySelector(selector); + const target = + saved.note === null + ? find(saved.control) + : [saved.note, ...saved.near] + .map((id) => find(`.note-card[data-note-id="${CSS.escape(id)}"] ${saved.control}`)) + .find(Boolean); + const to = target ?? find('#notes-search') ?? find('#side-add-note'); + to?.focus(); + return !!to; +} + +// The Notas tab: a button for a new note, a search over the text and the quote (which only +// changes the list, so that the field keeps the focus while it is typed in), the way to export, +// and the notes in reading order, each with the colour of its highlight. +export function renderNotes( + root: HTMLElement, + notes: readonly Note[], + state: NotesState, + actions: NotesActions, +) { + const any = notes.length > 0; + root.innerHTML = `${ + any + ? `
    ` + : `
    ${icon('note')}

    Nenhuma nota ainda

    Selecione um trecho para realçar ou anotar.

    ` + }`; + root.querySelector('#side-add-note')!.onclick = actions.add; + if (!any) return; + const list = root.querySelector('#notes-list')!, + count = root.querySelector('#notes-count')!, + search = root.querySelector('#notes-search')!; + const byId = (id: string | undefined) => notes.find((n) => n.id === id); + const draw = () => { + const shown = matchNotes(notes, state.query); + count.textContent = state.query.trim() + ? `${shown.length} de ${notes.length}` + : countLabel(notes); + list.innerHTML = shown.length + ? shown.map(card).join('') + : `
    ${icon('search')}

    Nenhuma nota encontrada

    Tente outras palavras do texto ou da citação.

    `; + }; + draw(); + search.oninput = () => { + state.query = search.value; + draw(); + }; + // Esc clears what was typed before it does anything else. + search.onkeydown = (e) => { + if (e.key !== 'Escape' || !search.value) return; + e.stopPropagation(); + e.preventDefault(); + search.value = ''; + state.query = ''; + draw(); + }; + root.querySelector('#side-export-notes')!.onclick = actions.exportNotes; + list.onclick = (e) => { + const target = e.target as Element; + const go = target.closest('[data-go], [data-go-quote]'), + edit = target.closest('[data-edit-note]'), + more = target.closest('[data-more]'); + if (go) { + const note = byId(go.dataset.go ?? go.dataset.goQuote); + if (note) actions.go(note); + } else if (edit) { + const note = byId(edit.dataset.editNote); + if (note) actions.edit(note); + } else if (more) { + // A second click on the button that is open closes its menu, like the other menus do. + if (more.getAttribute('aria-expanded') === 'true') closePopover(true); + else { + const note = byId(more.dataset.more); + if (note) actions.menu(more, note); + } + } + }; +} diff --git a/src/ui/outline-view.ts b/src/ui/outline-view.ts new file mode 100644 index 0000000..ba5b46b --- /dev/null +++ b/src/ui/outline-view.ts @@ -0,0 +1,203 @@ +import { icon } from '../icons'; +import { esc } from './dom'; +import { + currentSection, + expandedBranches, + flattenOutline, + revealSection, + toggleBranch, + type Folds, + type OutlineEntry, + type OutlineRow, +} from '../reading/outline'; + +// How the tree is folded, kept by the caller so that it survives a change of tab. +export type OutlineState = { folds: Folds }; + +// What an item that points nowhere says, as its tooltip and when it is pressed. +export const NO_DESTINATION = 'Este item não tem destino neste documento.'; + +// The outline as a tree of buttons. The section being read is marked and brought into +// view without moving the focus; branches fold with a button or the arrow keys. The tree is one stop +// of Tab (a roving tabindex, as the list of thumbnails): the item that has the focus, or else the +// section being read, or else the first one. The arrows walk the rest. +export class OutlineView { + private rows: OutlineRow[]; + private items = new Map(); + private toggles = new Map(); + private groups = new Map(); + private current: string | null = null; + private stop: string | null = null; + private root: HTMLElement; + private hint: HTMLElement; + + constructor( + host: HTMLElement, + entries: readonly OutlineEntry[], + private state: OutlineState, + private go: (entry: OutlineEntry) => void, + ) { + this.rows = flattenOutline(entries); + const byId = new Map(this.rows.map((row) => [row.id, row])); + const render = (id: string): string => { + const row = byId.get(id)!, + entry = row.entry, + title = entry.title.trim() || 'Seção'; + // An item with no page (an external or broken destination) does nothing: it looks unavailable. + // It stays a button that the keyboard reaches, and pressing it says why. + const none = entry.page === null; + return `
  • ${ + row.children.length + ? `` + : '' + }
    ${ + row.children.length + ? `` + : '' + }
  • `; + }; + const top = this.rows.filter((row) => row.depth === 0).map((row) => row.id); + host.innerHTML = `Tab entra no sumário e sai dele. Use as setas para andar entre os itens, Direita e Esquerda para abrir e fechar as seções, e Enter para ir à seção.
      ${top.map(render).join('')}
    `; + this.hint = host.querySelector('#outline-keys')!; + this.root = host.querySelector('.outline-tree')!; + this.root + .querySelectorAll('[data-item]') + .forEach((button) => this.items.set(button.dataset.item!, button)); + this.root + .querySelectorAll('[data-toggle]') + .forEach((button) => this.toggles.set(button.dataset.toggle!, button)); + this.root + .querySelectorAll('[data-group]') + .forEach((group) => this.groups.set(group.dataset.group!, group)); + this.setStop(top[0]); + this.root.addEventListener('click', (event) => this.click(event)); + this.root.addEventListener('keydown', (event) => this.key(event)); + // Whatever puts the focus on a row (an arrow, a click, the drawing that gives it back) makes the + // item of that row the stop of Tab. + this.root.addEventListener('focusin', (event) => { + const target = (event.target as HTMLElement).closest( + '.outline-item, .outline-toggle', + ); + const id = target && (target.dataset.item ?? target.dataset.toggle); + if (id) this.setStop(id); + }); + } + + // Marks the section that holds the page. A new section opens the branches above it and + // scrolls into view; the same section leaves the list where the user put it. + show(page: number) { + const id = currentSection(this.rows, page); + const changed = id !== this.current; + if (changed) this.state.folds = revealSection(this.rows, this.state.folds, id); + this.current = id; + this.paint(); + // The reader who is not in the list finds Tab on the section being read; one who is keeps theirs. + if (id && !this.root.contains(document.activeElement) && this.seen(id)) this.setStop(id); + if (changed && id) this.items.get(id)?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + + // Only one item is a stop of Tab; the arrow keys move between them. + private setStop(id: string) { + const next = this.items.get(id); + if (!next) return; + if (this.stop && this.stop !== id) this.items.get(this.stop)?.setAttribute('tabindex', '-1'); + this.stop = id; + next.setAttribute('tabindex', '0'); + } + + // The stop must be on a row that can be seen: a branch that folds takes its children out of sight, + // and the stop goes to the branch itself. + private keepStop() { + if (this.stop && this.seen(this.stop)) return; + const row = this.rows.find((r) => r.id === this.stop); + const parent = row && [...row.ancestors].reverse().find((id) => this.seen(id)); + this.setStop(parent ?? this.rows[0].id); + } + + // Whether the item of a row is on screen: no branch above it is folded. + private seen(id: string) { + return !this.items.get(id)?.closest('[hidden]'); + } + + private paint() { + const open = expandedBranches(this.rows, this.state.folds, this.current); + for (const [id, group] of this.groups) { + group.hidden = !open.has(id); + this.toggles.get(id)?.setAttribute('aria-expanded', String(open.has(id))); + } + for (const [id, button] of this.items) { + if (id === this.current) button.setAttribute('aria-current', 'true'); + else button.removeAttribute('aria-current'); + } + this.keepStop(); + } + + private click(event: Event) { + const target = event.target as HTMLElement; + const toggle = target.closest('[data-toggle]'); + if (toggle) { + this.fold(toggle.dataset.toggle!); + return; + } + const item = target.closest('[data-item]'); + const row = item && this.rows.find((r) => r.id === item.dataset.item); + if (row) this.go(row.entry); + } + + private fold(id: string) { + this.state.folds = toggleBranch(this.rows, this.state.folds, id, this.current); + this.paint(); + } + + // Up and down walk the visible items; right opens a branch or goes into it, and left + // closes it or goes to its parent. The reader's own arrow keys do not run here. + private key(event: KeyboardEvent) { + // The toggle of a row answers to the same keys as the row's item. + const target = (event.target as HTMLElement).closest( + '.outline-item, .outline-toggle', + ); + if (!target || event.altKey || event.ctrlKey || event.metaKey) return; + const id = (target.dataset.item ?? target.dataset.toggle)!, + row = this.rows.find((r) => r.id === id)!, + item = this.items.get(id)!; + const visible = [...this.items.values()].filter((button) => !button.closest('[hidden]')); + const at = visible.indexOf(item); + const open = expandedBranches(this.rows, this.state.folds, this.current); + let focus: HTMLButtonElement | undefined; + switch (event.key) { + case 'ArrowDown': + focus = visible[Math.min(visible.length - 1, at + 1)]; + break; + case 'ArrowUp': + focus = visible[Math.max(0, at - 1)]; + break; + case 'Home': + focus = visible[0]; + break; + case 'End': + focus = visible[visible.length - 1]; + break; + case 'ArrowRight': + if (row.children.length && !open.has(id)) this.fold(id); + else if (row.children.length) focus = this.items.get(row.children[0]); + break; + case 'ArrowLeft': + if (row.children.length && open.has(id)) this.fold(id); + else if (row.ancestors.length) focus = this.items.get(row.ancestors.at(-1)!); + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + focus?.focus(); + } + + dispose() { + this.hint.remove(); + this.root.remove(); + this.items.clear(); + this.toggles.clear(); + this.groups.clear(); + } +} diff --git a/src/ui/overlays.ts b/src/ui/overlays.ts index b9ce88b..6c400d6 100644 --- a/src/ui/overlays.ts +++ b/src/ui/overlays.ts @@ -1,39 +1,231 @@ -import { $, esc } from './dom'; -let toastTimer: ReturnType; -export function toast(message: string, action?: { label: string; run: () => void }) { - clearTimeout(toastTimer); - const t = $('#toast'); - t.innerHTML = `${esc(message)}${action ? `` : ''}`; - t.hidden = false; - if (action) t.querySelector('button')!.onclick = action.run; - toastTimer = setTimeout(() => (t.hidden = true), action ? 9000 : 4500); -} -export function fail(error: unknown) { +import { $ } from './dom'; +import { isTypedCharacter, menuIndexAfterKey, typeaheadIndex } from './menu-navigation'; +import { placePopover, pointBox } from './popover-position'; +import { toast, placeToasts, dismissNotices } from './toast-stack'; +import { GENERIC_ERROR, describeError } from './error-text'; +export { + toast, + announce, + dismissNotices, + type ToastAction, + type ToastOptions, +} from './toast-stack'; +// An error that the reader was already told about: whoever catches it further up has nothing +// to add, so `fail` ignores it and the same failure is not shown twice. +export class ReportedError extends Error {} +// A failure, in a sentence the reader understands. It stays until it is closed, and the +// original text of the error is one click away, under "Detalhes". `context` says what was +// being done: "Não foi possível abrir este PDF." comes before the reason. When there is no +// rule for the reason, the context says it all; when the reason already says it, it stands alone. +export function fail(error: unknown, context?: string) { + if (error instanceof ReportedError) return; console.error(error); - toast(String(error)); + const { message, detail } = describeError(error); + const text = + !context || message.startsWith(context) + ? message + : `${context} ${message === GENERIC_ERROR ? 'Tente novamente.' : message}`; + toast(text, undefined, { kind: 'error', detail }); +} +// `fail` with its context, for a `.catch(...)`. +export const failing = (context: string) => (error: unknown) => fail(error, context); +// The fields a dialog can start on: the ones that take text or a choice, not a switch or a radio. +const START_FIELDS = + 'input:not([type="radio"]):not([type="checkbox"]):not([type="hidden"]):not(:disabled), textarea:not(:disabled), select:not(:disabled)'; +// Where the focus goes when a dialog opens: what the dialog marks with `autofocus`, else its first +// field, else its main action (never one that destroys). Without this the first stop is the X of the +// header, and keys typed right away (an Enter, even) land on it instead of on the task. +function focusStart(d: HTMLElement) { + const visible = (el: HTMLElement) => !el.closest('[hidden]'); + const target = + [...d.querySelectorAll('[autofocus]')].find(visible) ?? + [...d.querySelectorAll(START_FIELDS)].find(visible) ?? + d.querySelector('button.primary:not(.danger-button):not(:disabled)'); + target?.focus({ preventScroll: true }); } -export function modal(content: string) { +// Shows a dialog, from its start: at the top, with the focus where `focusStart` puts it. With `again` +// it is the dialog that is on screen, drawn again because a choice in it changed what it shows: the +// scroll stays, the focus stays on the control that had it (found by its `id`), and so do the panels +// (`
    `) that were open. The dialog is named by its first title (an `h2`), so that a screen +// reader says which dialog it is; one without a title has no name. The messages of the page (`toast`) +// that are information leave when a dialog opens, so that they do not cover its buttons; the errors +// stay. While it is open, the messages that arrive are shown inside it, at its top, where they can +// be seen and pressed. +let watchingDialog = false; +export function modal(content: string, again = false) { closePopover(); - $('#dialog-content').innerHTML = content; - const d = $('#dialog'); + if (!again) dismissNotices(); + const d = $('#dialog'), + body = $('#dialog-content'); + const active = again && body.contains(document.activeElement) ? document.activeElement : null; + const keep = active instanceof HTMLElement ? active.id : ''; + const panels = again ? [...body.querySelectorAll('details')].map((panel) => panel.open) : []; + body.innerHTML = content; + const title = body.querySelector('h2'); + if (title) { + title.id ||= 'dialog-title'; + d.setAttribute('aria-labelledby', title.id); + } else d.removeAttribute('aria-labelledby'); if (!d.open) d.showModal(); + placeToasts(); + // Back to the page when it closes, by any way (a button, Esc, the code). + if (!watchingDialog) { + watchingDialog = true; + d.addEventListener('close', placeToasts); + } + // The dialog is one element for all of them, and the browser keeps where it was scrolled to. + if (!again) d.scrollTop = 0; d.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => d.close())); + const found = keep ? document.getElementById(keep) : null; + if (found && body.contains(found) && !found.matches(':disabled')) { + body.querySelectorAll('details').forEach((panel, i) => (panel.open = panels[i] ?? panel.open)); + found.focus({ preventScroll: true }); + } else focusStart(d); return d; } -export function closePopover() { - $('#popover').hidden = true; +// The one floating panel: a menu of actions (role "menu") or a small dialog, like the quick +// brightness control. It is placed by `placePopover`, so that it always fits in the window. +export type PopoverOptions = { + // A list of actions: the panel is a menu, takes the focus, and the arrows move through it. + menu?: boolean; + // The accessible name of a menu. + label?: string; + // Where the pointer was (window coordinates): the panel opens there and not under the anchor. + at?: { x: number; y: number }; + // Who gets the focus back when the panel closes. The anchor, unless said otherwise. + owner?: HTMLElement; +}; +let anchorElement: HTMLElement | null = null; +// Where the anchor was when the panel was placed. +let anchorPlace = { top: 0, left: 0 }; +let expanded: HTMLElement | null = null; +let ownerCount = 0; +export function closePopover(restoreFocus = false) { + const panel = $('#popover'); + const owner = panel.dataset.owner && document.getElementById(panel.dataset.owner); + panel.hidden = true; + if (expanded) expanded.setAttribute('aria-expanded', 'false'); + expanded = null; + anchorElement = null; + // Only a button that opens the panel says it is expanded. A card that got the menu does not. + if (owner && owner.hasAttribute('aria-expanded')) owner.setAttribute('aria-expanded', 'false'); + if (restoreFocus && owner) owner.focus({ preventScroll: true }); + delete panel.dataset.owner; + delete panel.dataset.side; + panel.removeAttribute('role'); + panel.removeAttribute('aria-label'); + panel.removeAttribute('aria-labelledby'); } -export function popover(anchor: HTMLElement, content: string) { +const menuItems = (panel: HTMLElement) => [ + ...panel.querySelectorAll('[role="menuitem"]'), +]; +export function popover(anchor: HTMLElement, content: string, options: PopoverOptions = {}) { + closePopover(); const p = $('#popover'); p.innerHTML = content; + const owner = options.owner ?? anchor; + owner.id ||= `popover-owner-${++ownerCount}`; + p.dataset.owner = owner.id; + anchorElement = anchor; + const { top, left } = anchor.getBoundingClientRect(); + anchorPlace = { top, left }; + if (options.menu) { + p.setAttribute('role', 'menu'); + if (options.label) p.setAttribute('aria-label', options.label); + p.querySelectorAll('button').forEach((item) => { + item.setAttribute('role', 'menuitem'); + item.tabIndex = -1; + }); + // A menu at the pointer has no button to mark as open. + if (!options.at) { + anchor.setAttribute('aria-haspopup', 'menu'); + anchor.setAttribute('aria-expanded', 'true'); + expanded = anchor; + } + } + // Measured at its natural height, then placed in one step, so that it never shows in the wrong place. + p.style.maxHeight = 'none'; p.hidden = false; - const r = anchor.getBoundingClientRect(); - p.style.top = `${r.bottom + 8}px`; - p.style.left = `${Math.max(8, Math.min(innerWidth - 260, r.right - 248))}px`; + const box = p.getBoundingClientRect(); + const root = document.documentElement; + const place = placePopover({ + anchor: options.at ? pointBox(options.at.x, options.at.y) : anchor.getBoundingClientRect(), + width: box.width, + height: box.height, + viewport: { width: root.clientWidth || innerWidth, height: root.clientHeight || innerHeight }, + align: options.at ? 'start' : 'end', + gap: options.at ? 0 : undefined, + }); + p.style.left = `${place.left}px`; + p.style.top = `${place.top}px`; + p.style.maxHeight = `${place.maxHeight}px`; + p.dataset.side = place.side; + if (options.menu) menuItems(p)[0]?.focus({ preventScroll: true }); } document.addEventListener('pointerdown', (e) => { if (!(e.target as HTMLElement).closest('#popover, [data-menu], #reader-more')) closePopover(); }); +// The panel is placed once. If what it hangs from moves or the window changes, it closes. +addEventListener('resize', () => { + if (!$('#popover').hidden) closePopover(); +}); +document.addEventListener( + 'scroll', + (e) => { + const panel = $('#popover'); + const target = e.target; + if (panel.hidden || (target instanceof Node && panel.contains(target))) return; + if ( + !anchorElement || + !(target === document || (target instanceof Node && target.contains(anchorElement))) + ) + return; + // A scroll event can arrive just after the panel opened, for a scroll that happened before. + // Only a move of the anchor itself detaches the panel from it. + const now = anchorElement.getBoundingClientRect(); + if (Math.abs(now.top - anchorPlace.top) > 1 || Math.abs(now.left - anchorPlace.left) > 1) + closePopover(); + }, + true, +); +// The browser menu has nothing for the panel. Cards ask for their own (see the library). +document.addEventListener('contextmenu', (e) => { + if ((e.target as HTMLElement).closest?.('#popover')) e.preventDefault(); +}); +// Keys of a menu. Escape is handled with the other global keys, in shortcuts.ts. +let typed = '', + typedAt = 0; +document.addEventListener('keydown', (e) => { + const panel = $('#popover'); + if (panel.hidden || panel.getAttribute('role') !== 'menu') return; + if (!(e.target instanceof Node) || !panel.contains(e.target)) return; + // Tab leaves the menu: the focus goes back to the button, and Tab moves on from there. + if (e.key === 'Tab') { + closePopover(true); + return; + } + const items = menuItems(panel); + const enabled = items.map((item) => !(item as HTMLButtonElement).disabled); + const current = items.indexOf(document.activeElement as HTMLElement); + const moved = + e.ctrlKey || e.altKey || e.metaKey ? null : menuIndexAfterKey(e.key, current, enabled); + if (moved !== null) { + e.preventDefault(); + items[moved].focus(); + } else if (isTypedCharacter(e)) { + typed = e.timeStamp - typedAt > 700 ? e.key : typed + e.key; + typedAt = e.timeStamp; + const found = typeaheadIndex( + items.map((item) => item.textContent ?? ''), + enabled, + current, + typed, + ); + // Typing in a menu never reaches the page, even when no item starts with the letter. + e.preventDefault(); + if (found !== null) items[found].focus(); + } +}); document.addEventListener( 'click', (e) => { diff --git a/src/ui/popover-position.ts b/src/ui/popover-position.ts new file mode 100644 index 0000000..4d8dbdd --- /dev/null +++ b/src/ui/popover-position.ts @@ -0,0 +1,85 @@ +// Where a floating panel goes, as numbers only, so that the rule can be tested without a window. +// The panel opens under its anchor when it fits and above it when it does not (the other way round, +// for a panel that asks for `prefer: 'above'`). It never leaves the window (`margin` on every side) +// and, as a last resort, it scrolls inside. +export const POPOVER = Object.freeze({ + margin: 8, + gap: 8, + // Below this much room next to the anchor, the panel uses the whole window instead. + minHeight: 120, +}); + +export type Box = { left: number; top: number; right: number; bottom: number }; +export type PopoverInput = { + // The button, or a point (right = left, bottom = top) for a menu opened at the pointer. + anchor: Box; + // The natural size of the panel, measured without a height limit. + width: number; + height: number; + viewport: { width: number; height: number }; + // Which edge of the anchor the panel lines up with. If it does not fit there, the other one. + align?: 'start' | 'end'; + // The distance between anchor and panel. A menu at the pointer has none. + gap?: number; + // The side that is tried first. Below, unless the panel asks for above. + prefer?: 'below' | 'above'; +}; +export type PopoverPlacement = { + left: number; + top: number; + // What the panel may grow to: more than that scrolls inside it. + maxHeight: number; + side: 'below' | 'above' | 'window'; +}; + +const size = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0); +const coordinate = (n: number) => (Number.isFinite(n) ? n : 0); + +// A point as an anchor. +export function pointBox(x: number, y: number): Box { + return { left: x, top: y, right: x, bottom: y }; +} + +export function placePopover(input: PopoverInput): PopoverPlacement { + const { margin, minHeight } = POPOVER; + const gap = input.gap ?? POPOVER.gap; + const vw = size(input.viewport.width), + vh = size(input.viewport.height); + const room = { width: Math.max(0, vw - 2 * margin), height: Math.max(0, vh - 2 * margin) }; + const width = Math.min(size(input.width), room.width); + const height = size(input.height); + const anchor = { + left: coordinate(input.anchor.left), + top: coordinate(input.anchor.top), + right: coordinate(input.anchor.right), + bottom: coordinate(input.anchor.bottom), + }; + + const below = vh - margin - (anchor.bottom + gap); + const above = anchor.top - gap - margin; + let side: PopoverPlacement['side']; + let space: number; + const higher = input.prefer === 'above'; + if (!higher && height <= below) [side, space] = ['below', below]; + else if (height <= above) [side, space] = ['above', above]; + else if (height <= below) [side, space] = ['below', below]; + else [side, space] = below >= above ? ['below', below] : ['above', above]; + // No usable room on either side: the panel covers the anchor rather than shrink to nothing. + if (space < Math.min(height, minHeight)) [side, space] = ['window', room.height]; + + const shown = Math.min(height, space); + const top = + side === 'below' + ? anchor.bottom + gap + : side === 'above' + ? anchor.top - gap - shown + : Math.max(margin, Math.min(anchor.top, vh - margin - shown)); + + const start = anchor.left, + end = anchor.right - width; + const fits = (left: number) => left >= margin && left + width <= vw - margin; + const wanted = input.align === 'start' ? [start, end] : [end, start]; + const left = wanted.find(fits) ?? Math.max(margin, Math.min(vw - margin - width, wanted[0])); + + return { left, top, maxHeight: Math.floor(space), side }; +} diff --git a/src/ui/preferences-dialog.ts b/src/ui/preferences-dialog.ts index 6977f24..bae8410 100644 --- a/src/ui/preferences-dialog.ts +++ b/src/ui/preferences-dialog.ts @@ -2,7 +2,47 @@ import type { Preferences } from '../types'; import { invoke } from '../bridge'; import { icon } from '../icons'; import { $, esc, ib } from './dom'; -import { modal, fail } from './overlays'; +import { modal, failing } from './overlays'; +import { FAILED } from './failures'; +import { showHelp } from './help-dialog'; +import { PAPER_CHOICES } from './reading-comfort'; + +// The sections of the dialog, in the order of its tabs. +const SECTIONS = [ + ['aparencia', 'Aparência'], + ['leitura', 'Leitura'], + ['teclado', 'Teclado'], + ['biblioteca', 'Biblioteca'], +] as const; +export type PreferencesSection = (typeof SECTIONS)[number][0]; +// The section the dialog opened on last, for as long as the app stays open. +let lastSection: PreferencesSection = SECTIONS[0][0]; +// The keys of one letter, with what each does (the whole list is in the guide). +const SINGLE_KEYS = [ + ['K', 'Alternar o modo Kindle'], + ['L', 'Alternar duas páginas lado a lado'], + ['S', 'Mostrar ou esconder a barra lateral'], + ['D', 'Mudar o papel: claro, sépia e escuro'], + ['R', 'Girar a página'], + ['H', 'Realçar o trecho selecionado'], + ['N', 'Adicionar nota ao trecho selecionado'], + ['?', 'Abrir o guia'], +]; +// Up to this width the tabs lie across the top of the dialog instead of down its side. +const NARROW = '(max-width: 800px)'; +// The tab that an arrow key, Home or End goes to; the tabs wrap around. Null for other keys. Both the +// vertical and the horizontal arrows work, whichever way the tabs lie at the moment. +function tabAfterKey(at: number, key: string): number | null { + const last = SECTIONS.length - 1; + if (key === 'ArrowDown' || key === 'ArrowRight') return at === last ? 0 : at + 1; + if (key === 'ArrowUp' || key === 'ArrowLeft') return at === 0 ? last : at - 1; + if (key === 'Home') return 0; + if (key === 'End') return last; + return null; +} +const panel = (id: PreferencesSection, open: PreferencesSection, content: string) => + `
    ${content}
    `; +// `section` is the tab the dialog opens on; without it, the one that was used last. export function showPreferences( prefs: Preferences, actions: { @@ -10,28 +50,103 @@ export function showPreferences( applyPrefs: () => void; setReadingMode: (mode: Preferences['readingMode']) => void; }, + section?: PreferencesSection, ) { const { folder, applyPrefs, setReadingMode } = actions; - const d = - modal(`
    DO SEU JEITO

    Conforto para ler

    ${ib('close-settings', 'Fechar preferências', 'close', 'data-close')}
    -
    Interface

    A aparência da biblioteca e dos controles.

    -

    Rolagem contínua ou uma tela de cada vez.

    No Kindle, as telas acompanham o zoom e podem atravessar páginas do PDF. Use as setas, Espaço ou a roda do mouse. Um pequeno trecho se repete para não cortar linhas. O layout original é preservado.

    -
    Cor das páginas

    Aplicada à leitura, sem alterar seu PDF.

    ${[ - ['light', 'sun', 'Clara'], - ['sepia', 'leaf', 'Sépia'], - ['dark', 'moon', 'Escura'], - ] - .map( - ([v, i, l]) => - ``, - ) - .join('')}
    No modo escuro, as cores das imagens também são invertidas.
    + const open = (lastSection = SECTIONS.find(([id]) => id === section)?.[0] ?? lastSection); + const inKindle = prefs.readingMode === 'kindle'; + const tabs = SECTIONS.map( + ([id, label]) => + ``, + ).join(''); + const appearance = `

    A aparência da biblioteca e dos controles.

    +
    Cor das páginas

    Aplicada à leitura, sem alterar seu PDF.

    ${PAPER_CHOICES.map( + ([v, i, l]) => + ``, + ).join('')}
    No modo escuro, as cores das imagens também são invertidas.

    Escurece a área do PDF, mesmo com a página clara.

    ${prefs.brightness}%
    Mais suave · 10%Original · 100%
    -
    ${icon('warm')} Tela quente

    Um tom âmbar suave para a leitura noturna.

    ${prefs.warm}%
    DesativadaMais quente
    Um pouco de calma.A próxima página pode esperar um segundo.
    -
    ${icon('folder')} Biblioteca local${esc(folder)}
    `); +
    ${icon('warm')} Tela quente

    Um tom âmbar suave para a leitura noturna.

    ${prefs.warm}%
    DesativadaMais quente
    Amostra de texto com o papel, o brilho e a tela quente escolhidos.
    `; + const reading = `

    Rolagem contínua ou uma tela de cada vez.

    No Kindle, as telas acompanham o zoom e podem atravessar páginas do PDF. Use as setas, Espaço ou a roda do mouse. Um pequeno trecho se repete para não cortar linhas. A diagramação do texto é preservada.

    +

    + +
    No modo Kindle

    Disponível no modo Kindle.

    `; + const keyboard = ` +
    ${SINGLE_KEYS.map(([k, v]) => `
    ${v}
    ${k}
    `).join('')}
    `; + const library = `
    ${icon('folder')} Pasta da biblioteca${esc(folder)}
    +

    Os PDFs adicionados são copiados para esta pasta. Os originais não são alterados.

    `; + const d = + modal(`

    Preferências

    ${ib('close-settings', 'Fechar preferências', 'close', 'data-close')}
    +
    ${tabs}
    ${panel('aparencia', open, appearance)}${panel('leitura', open, reading)}${panel('teclado', open, keyboard)}${panel('biblioteca', open, library)}
    +
    `); + // One tab is open at a time. The others keep their controls, only out of sight, so that what is set + // in one tab never depends on having visited another. + const buttons = [...d.querySelectorAll('.prefs-tab')]; + const select = (id: PreferencesSection, focus = false) => { + lastSection = id; + for (const tab of buttons) { + const on = tab.dataset.section === id; + tab.classList.toggle('active', on); + tab.setAttribute('aria-selected', String(on)); + tab.tabIndex = on ? 0 : -1; + $(`#prefs-panel-${tab.dataset.section}`).hidden = !on; + if (on && focus) tab.focus(); + } + $('.prefs-panels').scrollTop = 0; + }; + buttons.forEach((tab) => (tab.onclick = () => select(tab.dataset.section as PreferencesSection))); + $('.prefs-tabs').addEventListener('keydown', (e) => { + if (e.altKey || e.ctrlKey || e.metaKey) return; + const next = tabAfterKey(buttons.indexOf(e.target as HTMLButtonElement), e.key); + if (next === null) return; + e.preventDefault(); + e.stopPropagation(); + select(SECTIONS[next][0], true); + }); + // The tabs say which way they lie, so that a screen reader names the arrows that move through them. + const narrow = matchMedia(NARROW); + const orient = () => + d + .querySelector('.prefs-tabs') + ?.setAttribute('aria-orientation', narrow.matches ? 'horizontal' : 'vertical'); + narrow.addEventListener('change', orient); + d.addEventListener('close', () => narrow.removeEventListener('change', orient), { once: true }); + // The Kindle needs one column. The choice is kept, only unavailable meanwhile. + const syncSpread = () => { + const kindle = prefs.readingMode === 'kindle'; + $('#pref-spread').disabled = kindle; + $('#pref-spread-help').textContent = kindle + ? 'Só na rolagem. O Kindle usa uma coluna; sua escolha volta ao sair dele.' + : 'Páginas lado a lado na rolagem. Com capa separada, a página 1 fica sozinha.'; + }; + // What only the Kindle uses stays in view in the scroll mode, switched off and saying why. + const syncKindleGroup = () => { + const on = prefs.readingMode === 'kindle'; + const group = $('#pref-kindle-group'); + group.disabled = !on; + if (on) group.removeAttribute('aria-describedby'); + else group.setAttribute('aria-describedby', 'pref-kindle-note'); + $('#pref-kindle-note').hidden = on; + }; + $('#pref-spread').value = prefs.spread; + $('#pref-spread').onchange = () => { + prefs.spread = $('#pref-spread').value as Preferences['spread']; + applyPrefs(); + }; + syncSpread(); $('#pref-reading-mode').value = prefs.readingMode; - $('#pref-reading-mode').onchange = () => + $('#pref-reading-mode').onchange = () => { setReadingMode($('#pref-reading-mode').value as Preferences['readingMode']); + syncKindleGroup(); + syncSpread(); + }; + $('#pref-join-pages').onchange = () => { + prefs.joinPages = $('#pref-join-pages').checked; + applyPrefs(); + }; + $('#pref-chapter-breaks').onchange = () => { + prefs.chapterBreaks = $('#pref-chapter-breaks').checked; + applyPrefs(); + }; $('#pref-theme').value = prefs.theme; $('#pref-theme').onchange = () => { prefs.theme = $('#pref-theme').value as Preferences['theme']; @@ -41,6 +156,11 @@ export function showPreferences( prefs.resume = $('#pref-resume').checked; applyPrefs(); }; + $('#pref-single-keys').onchange = () => { + prefs.singleKeys = $('#pref-single-keys').checked; + applyPrefs(); + }; + $('#prefs-shortcuts').onclick = showHelp; const sample = () => { const s = $('#paper-sample'); s.dataset.paper = prefs.paper; @@ -76,10 +196,12 @@ export function showPreferences( prefs.paper = b.dataset.paper as Preferences['paper']; applyPrefs(); sample(); - d.querySelectorAll('[data-paper]').forEach((x) => - x.classList.toggle('active', (x as HTMLElement).dataset.paper === prefs.paper), - ); + d.querySelectorAll('.paper-option').forEach((x) => { + const on = x.dataset.paper === prefs.paper; + x.classList.toggle('active', on); + x.setAttribute('aria-pressed', String(on)); + }); }), ); - $('#settings-folder').onclick = () => void invoke('open_folder').catch(fail); + $('#settings-folder').onclick = () => void invoke('open_folder').catch(failing(FAILED.folder)); } diff --git a/src/ui/purge-dialog.ts b/src/ui/purge-dialog.ts new file mode 100644 index 0000000..7198039 --- /dev/null +++ b/src/ui/purge-dialog.ts @@ -0,0 +1,34 @@ +import type { Book } from '../types'; +import { $, documents, esc, ib, size } from './dom'; +import { modal } from './overlays'; + +// Asks before documents of Removidos are deleted for good. Says how many, how much room it frees, +// that the copies are erased and the original is not touched, and that the reading history stays. +// The answer is true only for the destructive button; the focus starts on Cancelar. +export function confirmPurge( + books: Pick[], + emptying: boolean, +): Promise { + const total = books.reduce((sum, b) => sum + b.size, 0); + const one = books.length === 1; + const title = emptying + ? 'Esvaziar Removidos?' + : one + ? 'Excluir definitivamente este documento?' + : `Excluir definitivamente ${documents(books.length)}?`; + const what = one + ? `${esc(books[0].title)} (${size(total)}) será excluído da biblioteca.` + : `${documents(books.length)} (${size(total)}) serão excluídos da biblioteca.`; + return new Promise((resolve) => { + const d = modal( + `

    ${title}

    ${ib('close-purge', 'Fechar', 'close', 'data-close')}

    ${what}

    • As cópias guardadas pelo Fólio (o PDF e a capa) serão excluídas. Isso libera ${size(total)} e não pode ser desfeito.
    • O arquivo original, fora da biblioteca, não é afetado.
    • O histórico de leitura é mantido.
    `, + ); + let confirmed = false; + d.addEventListener('close', () => resolve(confirmed), { once: true }); + $('#purge-confirm').onclick = () => { + confirmed = true; + d.close(); + }; + $('#purge-cancel').focus(); + }); +} diff --git a/src/ui/reader-chrome.ts b/src/ui/reader-chrome.ts new file mode 100644 index 0000000..9f39894 --- /dev/null +++ b/src/ui/reader-chrome.ts @@ -0,0 +1,156 @@ +import type { Window } from '@tauri-apps/api/window'; +import { icon } from '../icons'; +import { $ } from './dom'; +import { closePopover } from './overlays'; + +// Bars overlay the reading surface in fullscreen. Revealing them never changes +// the PDF viewport, zoom, chapter boundaries or saved reading coordinates. +export class ReaderChrome { + private fullscreen = false; + private changing = false; + private revision = 0; + private keyboard = false; + private hover = { top: false, bottom: false }; + private visible = { top: false, bottom: false }; + private timers: Partial>> = {}; + + constructor( + private window: Window, + private reading: () => boolean, + private resize: () => void, + private fail: (error: unknown) => void, + ) { + $('#fullscreen').setAttribute('aria-pressed', 'false'); + document.addEventListener('pointermove', (event) => this.pointer(event)); + document.addEventListener('pointerdown', (event) => { + this.keyboard = false; + this.pointer(event); + }); + document.documentElement.addEventListener('pointerleave', () => { + this.hover = { top: false, bottom: false }; + this.schedule(); + }); + document.addEventListener( + 'keydown', + (event) => { + if (event.key === 'Tab') this.keyboard = true; + }, + true, + ); + document.addEventListener('focusin', () => { + if (!this.fullscreen) return; + if (this.keyboard && $('.reader-top').contains(document.activeElement)) this.show('top'); + if (this.keyboard && $('.reader-footer').contains(document.activeElement)) + this.show('bottom'); + }); + document.addEventListener('focusout', () => this.schedule()); + new MutationObserver(() => this.schedule()).observe($('#popover'), { + attributes: true, + attributeFilter: ['hidden'], + }); + globalThis.addEventListener('resize', () => void this.sync().catch(fail)); + globalThis.addEventListener('focus', () => void this.sync().catch(fail)); + } + + private pointer(event: PointerEvent) { + if (!this.fullscreen) return; + this.hover.top = event.clientY <= (this.visible.top ? $('.reader-top').offsetHeight + 10 : 12); + this.hover.bottom = + event.clientY >= + innerHeight - (this.visible.bottom ? $('.reader-footer').offsetHeight + 10 : 12); + for (const edge of ['top', 'bottom'] as const) { + if (this.hover[edge]) this.show(edge); + else this.schedule(edge); + } + } + + private pinned(edge: 'top' | 'bottom') { + const element = $(edge === 'top' ? '.reader-top' : '.reader-footer'); + return ( + this.hover[edge] || + (this.keyboard && element.contains(document.activeElement)) || + (edge === 'top' && (!$('#popover').hidden || !$('#find-bar').hidden)) + ); + } + + private show(edge: 'top' | 'bottom') { + clearTimeout(this.timers[edge]); + this.timers[edge] = undefined; + this.visible[edge] = true; + document.body.classList.add(`reader-${edge}-visible`); + } + + private schedule(only?: 'top' | 'bottom') { + if (!this.fullscreen) return; + for (const edge of only ? [only] : (['top', 'bottom'] as const)) { + if (this.pinned(edge)) { + if (edge === 'top') this.show(edge); + continue; + } + if (this.timers[edge]) continue; + this.timers[edge] = setTimeout(() => { + this.timers[edge] = undefined; + if (this.pinned(edge)) return; + this.visible[edge] = false; + document.body.classList.remove(`reader-${edge}-visible`); + }, 650); + } + } + + private apply(active: boolean) { + if (this.fullscreen === active) return; + this.fullscreen = active; + for (const edge of ['top', 'bottom'] as const) clearTimeout(this.timers[edge]); + this.timers = {}; + this.visible = { top: false, bottom: false }; + this.hover = { top: false, bottom: false }; + closePopover(); + document.body.classList.toggle('reader-fullscreen', active); + document.body.classList.remove('reader-top-visible', 'reader-bottom-visible'); + const button = $('#fullscreen'); + button.innerHTML = icon(active ? 'collapse' : 'expand'); + // The name stays "Tela cheia (F11)"; whether it is on is in `aria-pressed` and in the icon. + button.setAttribute('aria-pressed', String(active)); + if (active) { + $('#viewer-container').focus({ preventScroll: true }); + if (!$('#find-bar').hidden) this.show('top'); + } + this.resize(); + } + + async sync() { + if (this.changing) return; + const revision = ++this.revision; + const active = await this.window.isFullscreen(); + if (!this.changing && revision === this.revision) this.apply(active && this.reading()); + } + + async toggle() { + if (this.changing || !this.reading()) return; + this.changing = true; + ++this.revision; + try { + const active = !(await this.window.isFullscreen()); + await this.window.setFullscreen(active); + this.apply(active); + } catch (error) { + this.fail(error); + } finally { + this.changing = false; + } + } + + async exit() { + if (!this.fullscreen) return; + ++this.revision; + await this.window.setFullscreen(false); + this.apply(false); + } + + showTop() { + if (this.fullscreen) this.show('top'); + } + releaseTop() { + this.schedule('top'); + } +} diff --git a/src/ui/reading-comfort.ts b/src/ui/reading-comfort.ts new file mode 100644 index 0000000..98d45bc --- /dev/null +++ b/src/ui/reading-comfort.ts @@ -0,0 +1,145 @@ +import type { Preferences } from '../types'; +import { $, ib } from './dom'; +import { closePopover, popover } from './overlays'; +import { icon } from '../icons'; + +// The papers of the reader, with the icon and the name of each. The panel and the preferences show +// the same three, in the order that the D key goes through them. +export const PAPER_CHOICES: readonly (readonly [Preferences['paper'], string, string])[] = [ + ['light', 'sun', 'Clara'], + ['sepia', 'leaf', 'Sépia'], + ['dark', 'moon', 'Escura'], +]; +// The two sliders: how far each goes, where "Restaurar" and "Desativar" take it, and its name. +const CONTROLS = { + brightness: { name: 'Brilho', min: 10, max: 100, reset: 100, action: 'Restaurar' }, + warm: { name: 'Tela quente', min: 0, max: 70, reset: 0, action: 'Desativar' }, +} as const; +type Control = keyof typeof CONTROLS; +const KINDS = Object.keys(CONTROLS) as Control[]; +const BUTTON = 'reader-appearance'; +const DEFAULT_LABEL = 'Aparência: papel, brilho e tela quente'; + +const panelOpen = () => { + const panel = $('#popover'); + return !panel.hidden && panel.dataset.owner === BUTTON; +}; + +// What the panel shows of the preferences: the paper that is on, and each value on its slider. +function syncPanel(prefs: Preferences) { + if (!panelOpen()) return; + for (const button of $('#popover').querySelectorAll('[data-quick-paper]')) { + const on = button.dataset.quickPaper === prefs.paper; + button.classList.toggle('active', on); + button.setAttribute('aria-pressed', String(on)); + } + for (const kind of KINDS) { + const range = $(`#quick-${kind}`); + range.value = String(prefs[kind]); + range.setAttribute('aria-valuetext', `${prefs[kind]}%`); + $(`#quick-${kind}-value`).textContent = `${prefs[kind]}%`; + } +} + +// The button of the toolbar: a dot on its icon says that the brightness or the warm screen is +// changed, and the name says by how much, so that the state is not only a dot. +export function syncReadingComfort(prefs: Preferences) { + const changes = [ + ...(prefs.brightness < 100 ? [`brilho ${prefs.brightness}%`] : []), + ...(prefs.warm > 0 ? [`tela quente ${prefs.warm}%`] : []), + ]; + const button = $(`#${BUTTON}`); + const label = changes.length ? `Aparência: ${changes.join(', ')}` : DEFAULT_LABEL; + button.toggleAttribute('data-adjusted', changes.length > 0); + button.setAttribute('aria-label', label); + button.title = prefs.singleKeys ? `${label} (D muda o papel)` : label; + syncPanel(prefs); +} + +// The panel of the toolbar button: the paper, the brightness and the warm screen, where the reader +// is, and a way on to the preferences for the rest. `more` opens them on the Aparência section. +export function bindReadingComfort(prefs: Preferences, apply: () => void, more: () => void) { + const button = $(`#${BUTTON}`); + const slider = (kind: Control) => { + const { name, min, max, action } = CONTROLS[kind]; + return `
    ${prefs[kind]}%
    `; + }; + const papers = () => + PAPER_CHOICES.map( + ([paper, ico, name]) => + ``, + ).join(''); + // Keys inside the panel. The fields (the sliders) keep their arrows. On a button the arrows of the + // reader would turn the page, so they stay here, and move between the three papers. Tab leaves the + // panel at either end, the focus going back to the button as it does from a menu. + const keys = (root: HTMLElement) => + root.addEventListener('keydown', (e) => { + if (e.altKey || e.ctrlKey || e.metaKey) return; + const target = e.target as HTMLElement; + if (e.key === 'Tab') { + const stops = [...root.querySelectorAll('button, input')]; + if (e.shiftKey && target === stops[0]) { + e.preventDefault(); + closePopover(true); + } else if (!e.shiftKey && target === stops.at(-1)) closePopover(true); + return; + } + if (target.matches('input')) return; + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) + return; + e.stopPropagation(); + const choices = [...root.querySelectorAll('[data-quick-paper]')]; + const at = choices.indexOf(target); + if (at < 0) return; + e.preventDefault(); + const next = + e.key === 'Home' + ? 0 + : e.key === 'End' + ? choices.length - 1 + : (at + (['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 1) + choices.length) % + choices.length; + choices[next].focus(); + }); + const open = () => { + if (button.getAttribute('aria-expanded') === 'true') { + closePopover(true); + return; + } + popover( + button, + `
    Aparência${ib('quick-close', 'Fechar aparência', 'close')}
    Cor das páginas
    ${papers()}
    ${KINDS.map(slider).join('')}
    `, + ); + const panel = $('#popover'); + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-labelledby', 'quick-label'); + button.setAttribute('aria-expanded', 'true'); + for (const kind of KINDS) { + const range = $(`#quick-${kind}`); + const set = (value: number) => { + prefs[kind] = value; + apply(); + syncPanel(prefs); + }; + range.oninput = () => set(Number(range.value)); + $(`#quick-${kind}-reset`).onclick = () => set(CONTROLS[kind].reset); + } + panel.querySelectorAll('[data-quick-paper]').forEach( + (choice) => + (choice.onclick = () => { + prefs.paper = choice.dataset.quickPaper as Preferences['paper']; + apply(); + syncPanel(prefs); + }), + ); + $('#quick-close').onclick = () => closePopover(true); + // The focus goes back to the button first, so that the dialog returns it there when it closes. + $('#quick-more').onclick = () => { + closePopover(true); + more(); + }; + keys($('.appearance')); + panel.querySelector('.quick-paper.active')?.focus({ preventScroll: true }); + }; + button.onclick = open; +} diff --git a/src/ui/selection-bar.ts b/src/ui/selection-bar.ts new file mode 100644 index 0000000..063f579 --- /dev/null +++ b/src/ui/selection-bar.ts @@ -0,0 +1,221 @@ +import { icon } from '../icons'; +import { COLOR_LABELS, NOTE_COLORS, rememberedColor } from '../reading/note-colors'; +import type { SelectionCapture } from '../reading/selection-capture'; +import type { NoteColor } from '../types'; +import { POPOVER, placePopover } from './popover-position'; + +export type SelectionBarOptions = { + // The empty bar that is in the page (`#highlight-bar`), and the reading surface it belongs to. + bar: HTMLElement; + container: HTMLElement; + // Whether a book is open, drawn, and with nothing over it: no dialog, no message to answer. + enabled: () => boolean; + // What is selected in the pages now (see reader.ts), or null. + capture: () => SelectionCapture | null; + highlight: (color: NoteColor) => void; + annotate: () => void; + copy: () => void; + // The live region that tells a screen reader that a passage is selected, and what can be done with + // it (the bar is not read out when it appears); `hint` is what it says. + status: HTMLElement; + hint: () => string; +}; + +// After a selection that ends this long (milliseconds), the bar shows, for a selection made by the +// keyboard or any other way that does not end with the mouse button. +const SETTLE = 250; + +// The small bar that floats near a passage that is selected in the pages: to highlight +// it in one of four colours, to annotate it, or to copy it. It does not take the selection or the +// focus from the page (the bar's buttons act on the mousedown that would have taken them), so the +// text stays selected while it is used. It goes away when the selection does, when the page +// scrolls, or the window changes, when the reader clicks elsewhere, and with Esc (see shortcuts.ts). +export class SelectionBar { + private dragging = false; + private announced = false; + private timer: ReturnType | undefined; + + constructor(private options: SelectionBarOptions) { + const { bar, container } = options; + // The tooltip of the colour that H uses gets "(H)" each time the bar is shown (see `show`). + const swatch = (color: NoteColor) => + ``; + bar.innerHTML = `${NOTE_COLORS.map(swatch).join('')}`; + // The mouse button would take the focus, and with it the selection: it does neither. + bar.addEventListener('mousedown', (e) => e.preventDefault()); + bar.addEventListener('click', (e) => { + const button = (e.target as Element).closest('button'); + if (!button) return; + this.hide(); + if (button.dataset.color) options.highlight(button.dataset.color as NoteColor); + else if (button.dataset.action === 'note') options.annotate(); + else options.copy(); + }); + document.addEventListener( + 'pointerdown', + (e) => { + if (e.target instanceof Node && bar.contains(e.target)) return; + // A click anywhere else starts something new: the bar waits for the mouse button to end it. + this.hide(); + this.dragging = e.button === 0 && e.target instanceof Node && container.contains(e.target); + }, + true, + ); + for (const type of ['pointerup', 'pointercancel']) + document.addEventListener( + type, + () => { + if (!this.dragging) return; + this.dragging = false; + this.schedule(30); + }, + true, + ); + document.addEventListener('selectionchange', () => { + if (this.dragging) return; + // A selection that went away takes the bar with it at once; one that came needs a moment + // to be the one the reader meant. + if (!this.selecting()) { + this.hide(); + this.forget(); + } else this.schedule(SETTLE); + }); + document.addEventListener( + 'scroll', + (e) => { + const target = e.target; + if (bar.hidden) return; + if (target === document || (target instanceof Node && target.contains(container))) + this.hide(); + }, + true, + ); + addEventListener('resize', () => this.hide()); + } + + get visible() { + return !this.options.bar.hidden; + } + + // Whether the reader has a passage selected in the pages, shown or not. + selecting() { + const selection = getSelection(); + return ( + !!selection && + !selection.isCollapsed && + !!selection.anchorNode && + !!selection.focusNode && + this.options.container.contains(selection.anchorNode) && + this.options.container.contains(selection.focusNode) + ); + } + + hide() { + clearTimeout(this.timer); + this.options.bar.hidden = true; + delete this.options.bar.dataset.side; + } + + // Esc: the bar and the selection both go. Says whether there was either. + dismiss() { + const had = this.visible || this.selecting(); + this.hide(); + if (this.selecting()) getSelection()?.removeAllRanges(); + this.forget(); + return had; + } + + // The keyboard moved the end of the selection, and the page went with it: the bar is put away at + // once (a scroll would take it, and with it the wait for it to come back) and comes again near the + // new end. + follow() { + this.hide(); + this.schedule(SETTLE); + } + + // The next passage that is selected is announced again. + private forget() { + this.announced = false; + this.options.status.textContent = ''; + } + + private schedule(delay: number) { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.update(), delay); + } + + private update() { + if (this.dragging || !this.options.enabled() || !this.selecting()) return this.hide(); + const capture = this.options.capture(); + if (!capture) return this.hide(); + this.show(capture); + // Said once for each passage: the bar that comes back as the selection grows is not news. + if (!this.announced && this.visible) { + this.announced = true; + this.options.status.textContent = this.options.hint(); + } + } + + // Above the first line of the passage, over the text that comes before it, when the reading surface + // has room there: the text after the passage is what is read next, and the bar does not hide it. + // Without that room it goes under the last line, at its end; and, when the passage fills the + // screen, at the foot of the reading surface. Never out of the window (see popover-position.ts). + private show(capture: SelectionCapture) { + const { bar, container } = this.options; + const area = container.getBoundingClientRect(); + const lines = capture.pieces + .flatMap((piece) => piece.lines) + .filter( + (l) => + l.bottom > area.top && l.top < area.bottom && l.right > area.left && l.left < area.right, + ); + if (!lines.length) return this.hide(); + const first = lines[0], + last = lines[lines.length - 1]; + // The colour that the shortcut would use is marked. + // Its tooltip says the key: H highlights in that colour, and in no other. + const remembered = rememberedColor(); + for (const swatch of bar.querySelectorAll('.hl-swatch')) { + const last = swatch.dataset.color === remembered, + name = swatch.getAttribute('aria-label') ?? ''; + swatch.toggleAttribute('data-last', last); + swatch.title = last ? `${name} (H)` : name; + } + bar.hidden = false; + const box = bar.getBoundingClientRect(); + const root = document.documentElement; + const viewport = { + width: root.clientWidth || innerWidth, + height: root.clientHeight || innerHeight, + }; + const gap = 6; + // Room above the first line, inside the reading surface and not over the bars that are above it. + const above = first.top - Math.max(area.top, POPOVER.margin) >= box.height + gap; + // Centred on the first line when it goes above, and on the end of the passage when it goes under; + // the placement keeps it inside the window. + const middle = above ? (first.left + first.right) / 2 : last.right; + const centre = Math.min(Math.max(middle, area.left), area.right) - box.width / 2; + const place = placePopover({ + anchor: { + left: centre, + right: centre + box.width, + top: above ? first.top : Math.max(first.top, area.top), + bottom: above ? first.top : Math.min(last.bottom, area.bottom), + }, + width: box.width, + height: box.height, + viewport, + align: 'start', + gap, + prefer: above ? 'above' : 'below', + }); + if (place.side === 'window') { + bar.style.left = `${Math.max(8, Math.min(viewport.width - 8 - box.width, area.left + (area.width - box.width) / 2))}px`; + bar.style.top = `${Math.max(8, Math.min(area.bottom, viewport.height) - box.height - 16)}px`; + } else { + bar.style.left = `${place.left}px`; + bar.style.top = `${place.top}px`; + } + bar.dataset.side = place.side; + } +} diff --git a/src/ui/shell.ts b/src/ui/shell.ts index f7b2146..9a44dd3 100644 --- a/src/ui/shell.ts +++ b/src/ui/shell.ts @@ -1,14 +1,29 @@ import { icon } from '../icons'; import { ib } from './dom'; +import { SIDEBAR_WIDTH } from '../reading/sidebar'; +// What the header of the reader says of saving, and only when there is something to say: nothing at +// rest, "Salvando…" when a save takes longer than a second, and the failure while it lasts. The +// position and the reading credit are saved the same way, so none of the texts names one of them. +export const SAVE_STATUS = { + saving: 'Salvando…', + retrying: 'Não foi possível salvar. Tentando novamente…', +}; +// How long a save may take before the header says so. +export const SLOW_SAVE = 1000; +// A button of the bar that appears when documents are selected. The text goes away on narrow +// windows, and the name stays in `aria-label` and in the tooltip. A toggle (favorite, finished) keeps +// its name and says its state in `aria-pressed`, as the buttons of the card and of the reader do. +const selectionAction = (id: string, ico: string, label: string, extra = '', attrs = '') => + ``; export const appShell = `
    -
    UM ESPAÇO PARA LER
    ${icon('search')}Ctrl K
    ${ib('theme-toggle', 'Alternar tema da interface', 'moon')}
    -

    Sua biblioteca

    Boas leituras começam por aqui.

    +
    ${icon('search')}Ctrl + K
    ${ib('theme-toggle', 'Alternar tema da interface', 'moon')}
    +

    Todos os documentos

    + -
    TODOS OS DOCUMENTOS
    ${ib('view-grid', 'Visualização em grade', 'grid')}${ib('view-list', 'Visualização em lista', 'list')}
    -
    +
    ${ib('view-grid', 'Visualização em grade', 'grid', 'role="radio" aria-checked="true"')}${ib('view-list', 'Visualização em lista', 'list', 'role="radio" aria-checked="false" tabindex="-1"')}
    + Tab entra na lista e sai dela. Use as setas, Home e End para andar entre os documentos, Enter para abrir ou restaurar e Espaço para selecionar. +
    -
    ${icon('folder')} Os PDFs adicionados são copiados para sua biblioteca.
    +
    -