From 6d6193d8edf12540a85c1462d059102a3c94ab2a Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 16:06:33 -0600 Subject: [PATCH 01/16] chore: agregar hooks de git del flujo de versionado commit-msg exige Conventional Commits y pre-commit bloquea commits directos sobre main y beta. El script prepare cablea core.hooksPath en cada npm install. Co-Authored-By: Claude Sonnet 5.5 --- .githooks/README.md | 28 +++++++++++++++++++++++ .githooks/commit-msg | 53 ++++++++++++++++++++++++++++++++++++++++++++ .githooks/pre-commit | 36 ++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 118 insertions(+) create mode 100644 .githooks/README.md create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit diff --git a/.githooks/README.md b/.githooks/README.md new file mode 100644 index 0000000..ad26b93 --- /dev/null +++ b/.githooks/README.md @@ -0,0 +1,28 @@ +# .githooks — enforcement de versionado (Atenea) + +Hooks git-native (POSIX, sin dependencias). Corren para **cualquier** commit, hecho por una +persona o por un agente. Alinean el manual de versionado del equipo. + +- **`commit-msg`** — bloquea el commit si el mensaje no sigue Conventional Commits + (`()(!): descripción`). Avisa (no bloquea) si el tipo no cuadra con el prefijo + de la rama. +- **`pre-commit`** — bloquea commits directos sobre `main` o `beta` (el desarrollo va en ramas + temporales). Escape para promoción de release: `ATENEA_ALLOW_PROTECTED=1 git commit ...`. + +## Activación + +`core.hooksPath` es config local por clon (no se versiona), así que cada clon la activa una vez. +El proyecto lo automatiza con un script `prepare` en `package.json`: + +```json +"scripts": { "prepare": "git config core.hooksPath .githooks || true" } +``` + +Así, en cada `yarn install` queda cableado. Manual, si hiciera falta: + +```bash +git config core.hooksPath .githooks +chmod +x .githooks/* +``` + +Bypass puntual de un hook (no recomendado): `git commit --no-verify`. diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..7c28129 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,53 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────── +# Atenea · commit-msg · valida Conventional Commits (manual de versionado Venteks) +# +# Git-native: corre para CUALQUIER commit (humano o agente), esté o no Claude. +# Bloquea el commit si el mensaje no cumple. Escape puntual: git commit --no-verify. +# +# Formato exigido: ()(!): +# tipos: feat fix docs style refactor perf test build ci chore revert +# ejemplos: feat(auth): agregar login por sesión +# fix: corregir cálculo de permisos +# feat(store)!: cambiar shape del estado (BREAKING CHANGE) +# ───────────────────────────────────────────────────────────────────────── + +msg_file="$1" +first_line="$(head -n 1 "$msg_file")" + +# Saltar commits de merge/revert automáticos y fixup/squash. +case "$first_line" in + "Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;; +esac + +pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?(!)?: .+' + +if printf '%s' "$first_line" | grep -Eq "$pattern"; then + # Aviso suave: coherencia tipo ↔ prefijo de rama (no bloquea). + # Una rama feat/* es un contenedor de feature: acumula fix/docs/refactor/chore/ + # test intermedios de forma legítima, así que NO se avisa sobre ella. Solo se + # revisan fix/* y hotfix/*, que son de propósito único. + branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo '')" + type="$(printf '%s' "$first_line" | sed -E 's/^([a-z]+).*/\1/')" + case "$branch" in + fix/*) [ "$type" = "fix" ] || printf '⚠️ Atenea: rama fix/* pero commit "%s" (¿debería ser fix?)\n' "$type" >&2 ;; + hotfix/*) [ "$type" = "fix" ] || printf '⚠️ Atenea: rama hotfix/* pero commit "%s" (¿debería ser fix?)\n' "$type" >&2 ;; + esac + exit 0 +fi + +cat >&2 <<'EOF' +✕ Atenea · commit rechazado: el mensaje no sigue Conventional Commits. + + Formato: ()(!): + Tipos: feat fix docs style refactor perf test build ci chore revert + + Ejemplos válidos: + feat(auth): agregar login por sesión + fix: corregir cálculo de permisos + feat(store)!: cambiar shape del estado + + Manual de versionado del equipo (plugin Atenea, skill git-flow). + Bypass puntual (no recomendado): git commit --no-verify +EOF +exit 1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..b0b0088 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,36 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────── +# Atenea · pre-commit · protege ramas permanentes (manual de versionado Venteks) +# +# Regla del md: "nunca desarrolles directo sobre main". El desarrollo va en ramas +# temporales (feat/ fix/ chore/) desde beta; hotfix/ desde main. Este hook +# bloquea commits DIRECTOS sobre main o beta. +# +# Escape para promoción/merge de release: ATENEA_ALLOW_PROTECTED=1 git commit ... +# Bypass total puntual: git commit --no-verify +# ───────────────────────────────────────────────────────────────────────── + +[ "${ATENEA_ALLOW_PROTECTED:-}" = "1" ] && exit 0 + +branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo '')" + +case "$branch" in + main|beta) + cat >&2 < · fix/ · chore/ (desde beta) + hotfix/ (desde main) + + Crea la rama y muévete a ella: + git switch -c feat/mi-cambio + + Promoción de release (merge a $branch): ATENEA_ALLOW_PROTECTED=1 git commit ... + Bypass puntual (no recomendado): git commit --no-verify +EOF + exit 1 + ;; +esac + +exit 0 diff --git a/package.json b/package.json index bb2d904..c48f480 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "author": "AcertijosApps ", "scripts": { + "prepare": "git config core.hooksPath .githooks || true", "test": "node --import tsx --test \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit", "lint": "eslint \"src/**/*.{ts,tsx}\"", From 0c2750415cc76758ffd15da10fa56a0d74e893d8 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 16:06:33 -0600 Subject: [PATCH 02/16] =?UTF-8?q?docs:=20agregar=20gu=C3=ADa=20de=20contri?= =?UTF-8?q?buci=C3=B3n=20con=20el=20modelo=20de=20ramas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CONTRIBUTING.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c14c35f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contribuir + +Las contribuciones son bienvenidas. Mantén los cambios pequeños, enfocados y trazables. + +## Flujo de desarrollo + +Usa la versión de Node de [`.nvmrc`](./.nvmrc) e instala las dependencias desde la raíz: + +```sh +npm install +``` + +`npm install` también cablea los hooks de git del repositorio (`core.hooksPath` → `.githooks/`). + +El proyecto de ejemplo vive en `example/` (Expo) y consume el código de `src/` directamente, así que los cambios en TypeScript se ven con recarga en caliente. + +### Comandos + +```sh +npm run typecheck # tipos con TypeScript +npm run lint # ESLint +npm run format # Prettier (escribe) +npm run format:check # Prettier (solo verifica) +npm test # pruebas unitarias (node:test) +``` + +## Modelo de ramas + +Dos ramas permanentes, ambas protegidas: + +- **`main`**: producción. Sin pushes directos; los cambios entran solo con un Pull Request aprobado. +- **`beta`**: candidata a release. + +Ramas temporales, siempre creadas desde `beta`, en kebab-case: + +- `feat/` · `fix/` · `chore/` → Pull Request **a `beta`**, se borran al hacer merge. +- `hotfix/` es la única excepción: sale de `main`, va por PR a `main` y luego se integra de vuelta en `beta`. + +Una rama = un cambio enfocado. + +## Commits + +[Conventional Commits](https://www.conventionalcommits.org/es/): + +``` +()(!): +``` + +Tipos: `feat` `fix` `docs` `style` `refactor` `perf` `test` `build` `ci` `chore` `revert`. + +Correspondencia con [SemVer](https://semver.org/lang/es/): `feat` → MINOR, `fix` → PATCH, cambio incompatible (`feat!` o `BREAKING CHANGE:`) → MAJOR. + +Los hooks de [`.githooks/`](./.githooks) corren en cada commit: + +- `commit-msg` rechaza mensajes que no sean Conventional Commits. +- `pre-commit` bloquea commits directos sobre `main` y `beta`. + +Bypass puntual (no recomendado): `git commit --no-verify`. + +## Changelog + +Todo cambio que afecte el comportamiento actualiza [`CHANGELOG.md`](./CHANGELOG.md) ([Keep a Changelog](https://keepachangelog.com/es-ES/)): agrega la entrada bajo `## [Sin liberar]` en la categoría correcta (Añadido · Cambiado · Corregido · Eliminado). + +## Pull Requests + +Al abrir un PR: + +- Apunta a `beta` (o a `main` solo en `hotfix`). +- Mantenlo pequeño y centrado en un cambio. +- Asigna al menos un revisor; el merge requiere su aprobación. +- Los gates deben estar en verde: `npm run typecheck`, `npm run lint`, `npm run format:check` y `npm test`. El CI ([`.github/workflows/ci.yml`](./.github/workflows/ci.yml)) los corre en cada PR hacia `beta` o `main`. +- Incluye la entrada de `CHANGELOG.md`. + +## Release (`beta` → `main`) + +1. Confirma que los gates están en verde en `beta`. +2. Decide el incremento SemVer según los commits desde el último tag. +3. Actualiza `version` en `package.json`. +4. Renombra la sección `## [Sin liberar]` del changelog a `## [X.Y.Z] - AAAA-MM-DD` y abre una nueva vacía. +5. Abre el PR `beta` → `main` y haz merge. +6. Crea el tag `vX.Y.Z` sobre `main` y súbelo (`git push origin vX.Y.Z`). +7. Publica desde la raíz del repo con `npm publish` (el README y el `package.json` quedan congelados por versión). From 6c0a857a354e1f5eb0ea477039199f51bc8cef16 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 16:06:33 -0600 Subject: [PATCH 03/16] docs: agregar CHANGELOG con el historial de versiones Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a14e95e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +Todas las notas relevantes de este proyecto se documentan aquí. +Formato basado en [Keep a Changelog](https://keepachangelog.com/es-ES/); versionado [SemVer](https://semver.org/lang/es/). + +## [Sin liberar] + +### Añadido + +- CI en GitHub Actions para cada PR y push a `beta` y `main`: typecheck, lint, formato y tests. +- Hooks de git (`.githooks/`) que exigen Conventional Commits y bloquean commits directos sobre `main` y `beta`, y guía de contribución con el flujo de ramas. + +### Cambiado + +- El repositorio se mudó de GitLab a GitHub (`acertijosapps/react-native-md-document`): `repository` y `bugs` de `package.json` y `source` del podspec, que ahora usa el tag `vX.Y.Z`. + +### Corregido + +### Eliminado + +## [1.2.0] - 2026-10-25 + +### Añadido + +- Modo libro: en tablets y plegables en horizontal, el documento se muestra en dos páginas enfrentadas con volteo de página. Props `dualPage` (activo por defecto), `dualPageMinWidth` y `spreadGap` (separación para la bisagra de un plegable). +- `onPageChange` recibe un cuarto argumento, `pagesPerView` (1 o 2). +- Secciones «Modo libro» y «Plegables» en el README, con capturas. + +### Corregido + +- Paginación en tablets: ya no queda atascada ni deja páginas en blanco por eventos de layout tardíos. +- El modo libro solo se activa en pantallas de tablet o plegable (lado corto de la ventana de al menos 600 dp); un teléfono en horizontal sigue mostrando una página. + +## [1.1.2] - 2026-10-13 + +### Cambiado + +- `author`, correo y `homepage` de AcertijosApps en `package.json` y el podspec. + +## [1.1.1] - 2026-09-19 + +### Añadido + +- `homepage`, `repository` y `bugs` en `package.json`, y podspec alineado. + +## [1.1.0] - 2026-09-19 + +### Añadido + +- Carga del documento por `source` con estados de carga y error (`onLoad*`, `renderLoading`, `renderError`). + +## [1.0.1] - 2026-09-19 + +### Cambiado + +- README con el nombre del paquete publicado y las imágenes por URL absoluta, para que rendericen en npm; `media/` se incluye en el paquete. +- Podspec renombrado a `react-native-md-document.podspec`. +- Keywords `android`, `ios` y `zoom`. + +## [1.0.0] - 2026-09-19 + +### Añadido + +- Primera versión pública: visor de Markdown como PDF con volteo de página, zoom, resaltados persistentes, lectura en voz alta (TTS) y tokens de color claro/oscuro, con almacenamiento, fuente y voz inyectables. From cfa59022bc0539facf270e82ad486be9a3be5688 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 16:06:33 -0600 Subject: [PATCH 04/16] =?UTF-8?q?ci:=20correr=20el=20workflow=20tambi?= =?UTF-8?q?=C3=A9n=20en=20beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 199ac1f..ab63e7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: pull_request: - branches: [main] + branches: [beta, main] push: - branches: [main] + branches: [beta, main] concurrency: group: ci-${{ github.ref }} From 12ae2956e00c95755662946df962455548fc774a Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 19:11:31 -0600 Subject: [PATCH 05/16] fix(deps): limit peer ranges to the versions the library supports Reanimated 4 and Gesture Handler 3 are untested and Reanimated 4 changes the Babel plugin, so the open ranges let installs resolve to breakage. React Native 0.76 is the documented minimum. Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 16 ++++++++-------- package.json | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6bbef03..0fd9e72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "react-native-document", - "version": "0.0.1", + "name": "@acertijosapps/react-native-md-document", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "react-native-document", - "version": "0.0.1", + "name": "@acertijosapps/react-native-md-document", + "version": "1.2.0", "license": "MIT", "devDependencies": { "@react-native/eslint-config": "^0.76.5", @@ -22,10 +22,10 @@ "typescript": "^5.6.3" }, "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-gesture-handler": ">=2.0.0", - "react-native-reanimated": ">=3.0.0" + "react": ">=18.3.1", + "react-native": ">=0.76.0", + "react-native-gesture-handler": ">=2.20.0 <3", + "react-native-reanimated": ">=3.16.0 <4" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index c48f480..dc04640 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,10 @@ ], "license": "MIT", "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-gesture-handler": ">=2.0.0", - "react-native-reanimated": ">=3.0.0" + "react": ">=18.3.1", + "react-native": ">=0.76.0", + "react-native-gesture-handler": ">=2.20.0 <3", + "react-native-reanimated": ">=3.16.0 <4" }, "devDependencies": { "@react-native/eslint-config": "^0.76.5", From 96a359dbe1b229edaa7b6146d1e6318a05419b16 Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 19:11:32 -0600 Subject: [PATCH 06/16] fix(speech): name the published package in the native link error The message pointed at the old react-native-document name and was in Spanish. Also translates a theme token comment and renames the example app title. Co-Authored-By: Claude Sonnet 5.5 --- example/App.tsx | 2 +- example/package.json | 2 +- src/render/theme.ts | 4 ++-- src/speech/nativeEngine.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index c346786..e38ac98 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -131,7 +131,7 @@ export default function App(): React.JSX.Element { - react-native-document + react-native-md-document ; } diff --git a/src/speech/nativeEngine.ts b/src/speech/nativeEngine.ts index 3cb4881..e7f840c 100644 --- a/src/speech/nativeEngine.ts +++ b/src/speech/nativeEngine.ts @@ -12,7 +12,7 @@ interface NativeDocumentSpeech { } const LINK_ERROR = - 'Módulo nativo DocumentSpeech no enlazado. Instala react-native-document como dependencia nativa (pod install / gradle) o usa createMockSpeechEngine.'; + 'The DocumentSpeech native module is not linked. Install @acertijosapps/react-native-md-document as a native dependency (pod install / gradle) or use createMockSpeechEngine.'; export function createNativeSpeechEngine(): SpeechEngine { const native = NativeModules.DocumentSpeech as NativeDocumentSpeech | undefined; From 2a9e7a1932220d96b5a2143d4be72a07293b3e4b Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 19:12:45 -0600 Subject: [PATCH 07/16] feat(a11y): screen reader support for page turning and headings An invisible adjustable control exposes the current page and lets VoiceOver and TalkBack turn pages with swipe up/down (increment and decrement actions). The stacked previous/next layers used for the flip animation are hidden from the accessibility tree so only the visible pages are read, and headings get the header role. The spoken strings are overridable with the accessibilityLabels prop. Co-Authored-By: Claude Sonnet 5.5 --- src/flip/DocumentViewer.tsx | 22 +++++++++++ src/flip/PageControl.tsx | 65 +++++++++++++++++++++++++++++++++ src/flip/PageFlipper.tsx | 4 ++ src/flip/SpreadFlipper.tsx | 2 + src/flip/__tests__/a11y.test.ts | 39 ++++++++++++++++++++ src/flip/a11y.ts | 41 +++++++++++++++++++++ src/flip/index.ts | 2 + src/render/BlockList.tsx | 2 + 8 files changed, 177 insertions(+) create mode 100644 src/flip/PageControl.tsx create mode 100644 src/flip/__tests__/a11y.test.ts create mode 100644 src/flip/a11y.ts diff --git a/src/flip/DocumentViewer.tsx b/src/flip/DocumentViewer.tsx index 90a06b6..ba2f3e2 100644 --- a/src/flip/DocumentViewer.tsx +++ b/src/flip/DocumentViewer.tsx @@ -19,6 +19,9 @@ import type { ActiveRange, ColorTokens, DocColorScheme, DocTheme, Highlight } fr import { useDocTheme } from '../render'; import type { DocumentSource, ReadUri } from '../source'; import { useDocumentSource } from '../source'; +import type { ViewerAccessibilityLabels } from './a11y'; +import { mergeAccessibilityLabels } from './a11y'; +import PageControl from './PageControl'; import PageFlipper from './PageFlipper'; import SpreadFlipper from './SpreadFlipper'; @@ -42,6 +45,7 @@ export interface DocumentViewerProps { dualPage?: boolean; dualPageMinWidth?: number; spreadGap?: number; + accessibilityLabels?: Partial; onLoadComplete?: (document: MDDocument) => void; onLoadError?: (error: Error) => void; renderLoading?: () => React.ReactNode; @@ -61,6 +65,7 @@ interface ViewerCoreProps { dualPage?: boolean; dualPageMinWidth?: number; spreadGap?: number; + accessibilityLabels?: Partial; } function ViewerCore({ @@ -76,6 +81,7 @@ function ViewerCore({ dualPage = true, dualPageMinWidth = DUAL_PAGE_MIN_WIDTH, spreadGap = 0, + accessibilityLabels, }: ViewerCoreProps): React.JSX.Element { const [size, setSize] = useState({ width: 0, height: 0 }); const [pageIndex, setPageIndex] = useState(0); @@ -159,6 +165,10 @@ function ViewerCore({ }, []); const hasSize = size.width > 0 && size.height > 0; + const labels = useMemo( + () => mergeAccessibilityLabels(accessibilityLabels), + [accessibilityLabels], + ); return ( ) : null} + {hasSize && pagination.isMeasured ? ( + + ) : null} {hasSize && pagination.isMeasured && isSpread ? ( ); } @@ -290,6 +311,7 @@ function DocumentViewer(props: DocumentViewerProps): React.JSX.Element { dualPage={props.dualPage} dualPageMinWidth={props.dualPageMinWidth} spreadGap={props.spreadGap} + accessibilityLabels={props.accessibilityLabels} /> ); } diff --git a/src/flip/PageControl.tsx b/src/flip/PageControl.tsx new file mode 100644 index 0000000..1bfdb4b --- /dev/null +++ b/src/flip/PageControl.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { type AccessibilityActionEvent, StyleSheet, View } from 'react-native'; + +import { PAGE_PADDING } from '../pagination'; +import type { ViewerAccessibilityLabels } from './a11y'; +import { stepPageIndex, visiblePageRange } from './a11y'; + +export interface PageControlProps { + pageIndex: number; + pageCount: number; + pagesPerView: number; + labels: ViewerAccessibilityLabels; + onFlip: (nextIndex: number) => void; +} + +const CONTROL_HEIGHT = 44; + +function PageControl({ + pageIndex, + pageCount, + pagesPerView, + labels, + onFlip, +}: PageControlProps): React.JSX.Element { + const { first, last } = visiblePageRange(pageIndex, pagesPerView, pageCount); + const hasPrev = pageIndex > 0; + const hasNext = pageIndex + pagesPerView < pageCount; + + const handleAction = (event: AccessibilityActionEvent): void => { + const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1; + const next = stepPageIndex(pageIndex, direction, pagesPerView, pageCount); + if (next !== pageIndex) { + onFlip(next); + } + }; + + return ( + + ); +} + +const styles = StyleSheet.create({ + control: { + position: 'absolute', + top: 0, + left: PAGE_PADDING, + right: PAGE_PADDING, + height: CONTROL_HEIGHT, + }, +}); + +export default PageControl; diff --git a/src/flip/PageFlipper.tsx b/src/flip/PageFlipper.tsx index fb5fd94..f63e53c 100644 --- a/src/flip/PageFlipper.tsx +++ b/src/flip/PageFlipper.tsx @@ -94,6 +94,8 @@ function PageFlipper({ { + assert.deepEqual(visiblePageRange(2, 1, 12), { first: 3, last: 3 }); +}); + +test('visiblePageRange reports both pages of a spread', () => { + assert.deepEqual(visiblePageRange(2, 2, 12), { first: 3, last: 4 }); +}); + +test('visiblePageRange clamps the last page of an odd-length spread', () => { + assert.deepEqual(visiblePageRange(10, 2, 11), { first: 11, last: 11 }); +}); + +test('stepPageIndex moves by the pages shown and stays inside the document', () => { + assert.equal(stepPageIndex(2, 1, 2, 12), 4); + assert.equal(stepPageIndex(2, -1, 2, 12), 0); + assert.equal(stepPageIndex(0, -1, 1, 12), 0); + assert.equal(stepPageIndex(11, 1, 1, 12), 11); +}); + +test('default labels read the page position', () => { + assert.equal(DEFAULT_ACCESSIBILITY_LABELS.pages(3, 3, 12), 'Page 3 of 12'); + assert.equal(DEFAULT_ACCESSIBILITY_LABELS.pages(3, 4, 12), 'Pages 3 and 4 of 12'); +}); + +test('mergeAccessibilityLabels overrides only the given labels', () => { + const labels = mergeAccessibilityLabels({ nextPage: 'Página siguiente' }); + assert.equal(labels.nextPage, 'Página siguiente'); + assert.equal(labels.previousPage, 'Previous page'); +}); diff --git a/src/flip/a11y.ts b/src/flip/a11y.ts new file mode 100644 index 0000000..a47ef14 --- /dev/null +++ b/src/flip/a11y.ts @@ -0,0 +1,41 @@ +export interface ViewerAccessibilityLabels { + /** Label of the invisible page control that screen readers swipe to turn pages. */ + control: string; + /** Spoken position, e.g. "Page 3 of 12" or "Pages 3 and 4 of 12". */ + pages: (first: number, last: number, total: number) => string; + nextPage: string; + previousPage: string; +} + +export const DEFAULT_ACCESSIBILITY_LABELS: ViewerAccessibilityLabels = { + control: 'Document pages', + pages: (first, last, total) => + first === last ? `Page ${first} of ${total}` : `Pages ${first} and ${last} of ${total}`, + nextPage: 'Next page', + previousPage: 'Previous page', +}; + +export function mergeAccessibilityLabels( + overrides?: Partial, +): ViewerAccessibilityLabels { + return { ...DEFAULT_ACCESSIBILITY_LABELS, ...overrides }; +} + +export function visiblePageRange( + pageIndex: number, + pagesPerView: number, + pageCount: number, +): { first: number; last: number } { + const first = Math.min(pageIndex, Math.max(0, pageCount - 1)) + 1; + return { first, last: Math.min(first + pagesPerView - 1, Math.max(1, pageCount)) }; +} + +export function stepPageIndex( + pageIndex: number, + direction: 1 | -1, + pagesPerView: number, + pageCount: number, +): number { + const next = pageIndex + direction * pagesPerView; + return Math.min(Math.max(0, next), Math.max(0, pageCount - 1)); +} diff --git a/src/flip/index.ts b/src/flip/index.ts index aca8cda..9710848 100644 --- a/src/flip/index.ts +++ b/src/flip/index.ts @@ -1,3 +1,5 @@ +export type { ViewerAccessibilityLabels } from './a11y'; +export { DEFAULT_ACCESSIBILITY_LABELS } from './a11y'; export type { DocumentViewerProps } from './DocumentViewer'; export { default as DocumentViewer } from './DocumentViewer'; export type { PageFlipperProps } from './PageFlipper'; diff --git a/src/render/BlockList.tsx b/src/render/BlockList.tsx index addfeb8..e557995 100644 --- a/src/render/BlockList.tsx +++ b/src/render/BlockList.tsx @@ -148,6 +148,8 @@ function renderBlock(block: Block, ctx: RenderContext, key: string): React.JSX.E return ( Date: Sun, 1 Nov 2026 19:15:26 -0600 Subject: [PATCH 08/16] docs(readme): translate to English and document accessibility Also lists the supported peer ranges and the accessibilityLabels prop, and links the contributing guide. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 537 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 281 insertions(+), 256 deletions(-) diff --git a/README.md b/README.md index 72f96b8..bca475a 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,55 @@ > npm: [`@acertijosapps/react-native-md-document`](https://www.npmjs.com/package/@acertijosapps/react-native-md-document) -Visor de documentos **Markdown-como-PDF** para React Native: documento inmutable, con paso -de hoja animado, zoom, subrayado persistente, text-to-speech con resaltado sincronizado y -tema claro/oscuro. Sin librerías de terceros para el contenido (parser y render propios); el -storage, el origen del documento y el motor de voz son inyectables. +**Markdown-as-PDF** document viewer for React Native: an immutable document with animated +page-turning, zoom, persistent highlights, text-to-speech with synchronized word highlighting +and a light/dark theme. No third-party libraries for the content (own parser and renderer); the +storage, the document source and the speech engine are injectable. -## Vista previa +## Preview -Documento de demostración (`example/`) en tema claro y oscuro: banner con tokens de marca, -estilos en línea, resaltado y adaptación de color por tema. +Demo document (`example/`) in light and dark themes: banner with brand tokens, inline styles, +highlights and per-theme color adaptation. -| Claro | Oscuro | +| Light | Dark | |---|---| -| Tema claro | Tema oscuro | - -## Características - -- **Render Markdown** + **HTML embebido**: encabezados, párrafos, listas, blockquote, code, - `hr`, estilos en línea (negrita, cursiva, código, tachado, enlaces), **tablas** y HTML con - estilos inline (color, fondo, centrado, tamaños, anchos de columna). -- **Paso de hoja** animado (Reanimated + Gesture Handler), un gesto = una página. -- **Zoom** por pinch + arrastre, compuesto con el flip y el tap. -- **Subrayado** por palabra, **persistente entre sesiones** (storage inyectable). -- **Text-to-speech** con resaltado de la palabra hablada (incluso en tablas), pausa/reanudar, - velocidad, y auto-avance de página siguiendo la lectura. -- **Tema claro/oscuro** (`auto` sigue el SO) con tokens de color adaptables por tema. -- **Orígenes**: URL remota, ruta local del dispositivo, ruta del proyecto. -- **Orientación** vertical y horizontal (re-pagina al rotar). -- **Modo libro**: en tablets y plegables en horizontal muestra dos páginas lado a lado, con - el volteo de hoja desde el lomo. Activo por defecto; se desactiva con `dualPage={false}`. - -## Requisitos - -| Plataforma | Mínimo | Nota | +| Light theme | Dark theme | + +## Features + +- **Markdown** + **embedded HTML**: headings, paragraphs, lists, blockquotes, code, `hr`, + inline styles (bold, italic, code, strikethrough, links), **tables** and HTML with inline + styles (color, background, centering, sizes, column widths). +- Animated **page turning** (Reanimated + Gesture Handler), one gesture = one page. +- **Zoom** with pinch + drag, composed with the flip and the tap. +- **Per-word highlights**, **persistent across sessions** (injectable storage). +- **Text-to-speech** with the spoken word highlighted (even inside tables), pause/resume, + speed, and automatic page advance that follows the reading. +- **Light/dark theme** (`auto` follows the OS) with color tokens that adapt per theme. +- **Sources**: remote URL, local device path, bundled project file. +- **Portrait and landscape** orientation (re-paginates on rotation). +- **Book mode**: on tablets and foldables in landscape it shows two pages side by side, with + the page turning from the spine. On by default; turn it off with `dualPage={false}`. +- **Accessible**: screen readers (VoiceOver, TalkBack) hear the current page, can turn pages + with a swipe up/down, and skip the pages hidden behind the flip animation. + +## Requirements + +| Platform | Minimum | Note | |---|---|---| -| **iOS** | 13.0 | Deployment target del módulo nativo (`react-native-md-document.podspec`). TTS con resaltado por palabra incluido. | -| **Android** | 7.0 (API 24) | `minSdkVersion` del módulo. El TTS habla en API 24+, pero el **resaltado por palabra** durante la lectura usa `onRangeStart`, que requiere **Android 8.0 (API 26)**. | -| **React Native** | 0.76 | Probado con RN 0.76.5 / Expo SDK 52. | -| **Peer deps** | `react-native-reanimated` ≥ 3.16 · `react-native-gesture-handler` ≥ 2.20 | Únicas dependencias visuales. | +| **iOS** | 13.0 | Deployment target of the native module (`react-native-md-document.podspec`). TTS with per-word highlighting included. | +| **Android** | 7.0 (API 24) | The module's `minSdkVersion`. TTS speaks on API 24+, but the **per-word highlighting** while reading uses `onRangeStart`, which requires **Android 8.0 (API 26)**. | +| **React Native** | 0.76 | Tested with RN 0.76.5 / Expo SDK 52. | +| **Peer deps** | `react-native-reanimated` ≥ 3.16 and < 4 · `react-native-gesture-handler` ≥ 2.20 and < 3 | The only visual dependencies. Reanimated 4 and Gesture Handler 3 are not supported yet. | ### Android 16 KB (page size) -**Compatible.** `react-native-md-document` **no incluye código nativo compilado** -(`.so`): el módulo `DocumentSpeech` es Kotlin puro y se compila al dex de la -app. Al no aportar binarios, no hay librerías desalineadas de nuestra parte — -la librería cumple el requisito de 16 KB de Android 15 (Google Play, `targetSdk -35`) por construcción, en cualquier tamaño de página. +**Compatible.** `react-native-md-document` **ships no compiled native code** (`.so`): the +`DocumentSpeech` module is plain Kotlin and is compiled into the app's dex. Since it adds no +binaries, none of our libraries can be misaligned — the library meets Android 15's 16 KB +requirement (Google Play, `targetSdk 35`) by construction, on any page size. -## Instalación +## Installation ```sh # npm @@ -59,7 +60,7 @@ npm install @acertijosapps/react-native-md-document react-native-gesture-handler yarn add @acertijosapps/react-native-md-document react-native-gesture-handler react-native-reanimated ``` -`react-native-reanimated` requiere su plugin de Babel (último en la lista): +`react-native-reanimated` requires its Babel plugin (last in the list): ```js // babel.config.js @@ -69,7 +70,7 @@ module.exports = { }; ``` -La app debe envolver la raíz en `GestureHandlerRootView`: +The app must wrap its root in `GestureHandlerRootView`: ```tsx import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -79,92 +80,91 @@ export default function App() { } ``` -Para el **text-to-speech nativo**, el módulo `DocumentSpeech` se autoenlaza como cualquier -paquete RN: +For **native text-to-speech**, the `DocumentSpeech` module is autolinked like any RN package: ```sh -npx pod-install # iOS (o: cd ios && pod install) -# Android: autolinking por gradle, sin pasos extra +npx pod-install # iOS (or: cd ios && pod install) +# Android: Gradle autolinking, no extra steps ``` -### Dependencias opcionales +### Optional dependencies -- **Almacenamiento del subrayado**: la librería **no** depende de - `@react-native-async-storage/async-storage`. El store es inyectable - (`getItem`/`setItem`, sync o async): sirve AsyncStorage, MMKV, SQLite o el - tuyo. Instálalo **solo si** quieres persistir subrayados con esa opción; para - subrayado en memoria usa `createMemoryHighlightStore()`, y sin subrayado no - necesitas nada. Ver [Subrayado persistente](#subrayado-persistente). +- **Highlight storage**: the library does **not** depend on + `@react-native-async-storage/async-storage`. The store is injectable + (`getItem`/`setItem`, sync or async): AsyncStorage, MMKV, SQLite or your own will do. Install + it **only if** you want to persist highlights with that option; for in-memory highlights use + `createMemoryHighlightStore()`, and if you don't highlight you need nothing. See + [Persistent highlights](#persistent-highlights). -Únicas dependencias obligatorias: `react-native-reanimated` y -`react-native-gesture-handler` (los peers). +The only required dependencies are `react-native-reanimated` and +`react-native-gesture-handler` (the peers). -## Inicio rápido +## Quick start ```tsx import { DocumentViewer, parse } from '@acertijosapps/react-native-md-document'; -const doc = parse('# Hola\n\nUn documento **Markdown**.'); +const doc = parse('# Hello\n\nA **Markdown** document.'); export function Viewer() { return ; } ``` -`DocumentViewer` mide el contenido, lo pagina al tamaño del viewport y habilita flip + zoom. -`parse(source)` devuelve un `MDDocument` inmutable con `plainText` (la proyección de texto que -usan highlight y TTS). +`DocumentViewer` measures the content, paginates it to the viewport size and enables flip + +zoom. `parse(source)` returns an immutable `MDDocument` with `plainText` (the text projection +that highlights and TTS rely on). -## Orígenes del documento +## Document sources -`useDocumentSource` cubre los tres orígenes: +`useDocumentSource` covers the three sources: ```tsx import { DocumentViewer, useDocumentSource } from '@acertijosapps/react-native-md-document'; function Screen() { - // Proyecto (bundled): string o { markdown } + // Project (bundled): string or { markdown } const { document, isLoading, error, reload } = useDocumentSource({ markdown: MD_STRING }); - // URL remota: fetch integrado + // Remote URL: built-in fetch // useDocumentSource({ uri: 'https://example.com/doc.md' }) - // Ruta local del dispositivo + // Local device path // useDocumentSource({ uri: 'file:///…/doc.md' }) if (!document) { - return null; // isLoading / error / reload disponibles + return null; // isLoading / error / reload are available } return ; } ``` -Detección de string: con `http(s)://`, `file:`, `content:`, `asset:` o que empiece con `/` se -trata como URI; cualquier otro string es Markdown crudo. +String detection: a string starting with `http(s)://`, `file:`, `content:`, `asset:` or `/` is +treated as a URI; any other string is raw Markdown. -### Carga gestionada por el visor +### Viewer-managed loading -Si no quieres cablear el hook a mano, pasa `source` directo a `DocumentViewer`: carga solo y -expone los estados de carga. +If you don't want to wire the hook by hand, pass `source` straight to `DocumentViewer`: it +loads on its own and exposes the loading states. ```tsx console.log('listo', doc.length)} + onLoadComplete={(doc) => console.log('ready', doc.length)} onLoadError={(err) => console.warn(err.message)} renderLoading={() => } renderError={(err, retry) => ( {err.message} -