diff --git a/README.md b/README.md index ae4ab05..b33a801 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,16 @@ volumes: 继承变量上,所以一本按章节变行距的书不会被压平,一本给段落写死 `font-size: 14px` 的书也不会被字号滑块踩掉。这与「所有书看起来一致」的 Readium 路线正好相反。 -### 翻页 +### 翻页与章节导航 - **点击区域**:中间呼出/隐藏工具栏,左右翻页;可切换「左进右退」适配左右手。 +- **上一章 / 下一章**:页脚中间的 `‹` `›` 直接换章,不用先滚到章末。 + 到达书的第一章 / 最后一章时按钮置灰。跨窗口换章(一千二百章的书按 40 章一窗) + 会先取那一窗再落章,页脚在取窗期间显示「正在切换…」。 +- **页脚报位置**:`第 100/120 章 · 本章 1/3 页 · 40%`。章内页码与进度条同源、 + 同一次量测,所以两者不会互相矛盾。 +- **目录即跳转**:点任意一章直接跳过去,不在当前窗口的会先拉取那一窗。 + 跳转失败的章会明说「这一章暂时无法跳转」,不会静默无反应。 - **翻页动画**:滑动 / 淡入 / 无。系统开启「减少动态效果」时自动不播。 - **硬件翻页键**:`←` / `→`、`PageUp` / `PageDown`,空格播放/暂停朗读。 Android 外壳把音量键映射成方向键,所以音量键翻页、蓝牙翻页器都走同一条路。 diff --git a/docs/architecture.md b/docs/architecture.md index e9a3bc5..5a9f8f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -361,6 +361,46 @@ iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、A shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。 关键是**不往 shadow root 里注入任何 reset**。 +但隔离是双向的:**文档里的选择器进不去 shadow root**。这不是风格问题, +它决定了两条规则各自该写在哪儿,搞错了不会报错、只会静默失效: + +- `.book-flow`(正文列:版心宽度、页边距、字号缩放、分页列)、`.fixed-page`、 + 朗读高亮、翻页动画——这些元素在 shadow root **里**,所以规则必须注入到 + shadow root **里**(见 `ui/shadow.ts` 的 `FLOW_STYLESHEET`)。 + 写在 `styles/reader.css` 里等于删除:正文列会以浏览器默认样式渲染, + 没有版心、没有边距、翻页模式没有列。而外围 chrome 在文档里、样式完全正常, + 所以看上去不像样式表丢了,只像「阅读器没有页边距这个功能」。 +- `.book-host`、亮度过场(`::after` 伪元素)、`book-content` 的 `display` + ——这些是 shadow **host** 本身,属于文档树,所以规则写在 `styles/reader.css`。 +- host 上的状态属性(`data-paginated`、`data-animating`)只能用 `:host(...)` + 选择:从 shadow root 内部无法用类选择器匹配 host。 + 写成 `.book-host[data-paginated] .book-flow` 永远不会匹配, + 于是翻页模式静静地不产生任何列,翻页也就无处可翻。 + +两条规则里的**值**不重复:全部是自定义属性,而属性本身定义在 `:root` 上, +shadow root 会继承。 + +### 窗口化的三个接缝 + +「一份产物两个宿主」和「一千二百章的书不能先下载整本」是同一件事的两面, +而窗口是这件事的传输边界。跨过它有三个必须写死的地方: + +- **换窗口和落到章节是一次操作。** 只换窗口会留下*上一章*在屏幕上: + 宿主内容没动、`currentSectionIndex` 还指着旧窗口的下标、页脚还在报读者刚离开的章节。 + 这正是「点击章节没有反应」的症状。 +- **窗口接口只有一个名字。** 客户端建的 `StagedDoc` 叫 `setWindow`, + `StagedBook` 接口叫 `loadWindow`,屏幕曾经用鸭子类型去猜——猜错了, + 于是每次跨窗口跳转都是静默的空操作。现在由 `adoptWindow()` 一处识别。 +- **窗口由 manifest 的 `groups` 算出来,不是由「40 章」算出来。** + 漫画按*卷*开窗,而卷不是 40 页。`spine / 40` 会请求一个不存在的组, + 跳转就落到空处。 + +另外,位置必须按**整书坐标**判断,不能按窗口内的下标: +在窗口最后一章时下标是 39,拿它和窗口长度 40 比会得出「已经是最后一章」, +而后面还有八十章。窗口长度是传输细节,不该决定书在哪里结束。 + +### 阅读位置格式 + ### 书不能联网 加载时把章节里所有本地引用改写成内部 `reader-res:` 协议,渲染层从内存资源表解出; diff --git a/web/src/formats/types.ts b/web/src/formats/types.ts index da8cfd6..c6e3fe1 100644 --- a/web/src/formats/types.ts +++ b/web/src/formats/types.ts @@ -121,6 +121,8 @@ export interface StagedBook { * window does not contain it. */ loadWindow(content: unknown, spine: number): number; + /** Whole-book index of the loaded window's first section. */ + windowOffset?(): number; } export interface LoadContext { diff --git a/web/src/formats/windowed.ts b/web/src/formats/windowed.ts index b48b782..20c3e35 100644 --- a/web/src/formats/windowed.ts +++ b/web/src/formats/windowed.ts @@ -31,7 +31,7 @@ import type { BookContent, ContentItem } from '../net/api.ts'; import { RemoteZip, type RangeSource } from './remote-zip.ts'; -import type { BookDoc, Section, TocEntry } from './types.ts'; +import type { BookDoc, Section, StagedBook, TocEntry } from './types.ts'; /** * How a staged section's bytes are obtained. @@ -67,6 +67,13 @@ export interface StagedDoc extends BookDoc { * * Returns the local index of `spine` within the new window, or -1 when the new * window does not contain it. + * + * Named `setWindow` here and `loadWindow` on `StagedBook` — and one of those + * two names is the reason this comment exists. The screen used to duck-type a + * `loadWindow` off the document, found nothing (the method was called + * `setWindow`), and so never swapped a window: tapping chapter 900 of a + * 1200-chapter book did nothing at all. The duck type is gone; the contract is + * this interface, and `adoptWindow` below is the single implementation of it. */ setWindow(content: BookContent, spine: number): number; /** Whole-book index of the loaded window's first section. */ @@ -125,6 +132,14 @@ export function createStagedDoc(options: StagedDocOptions): StagedDoc { return section; }, setWindow(content: BookContent, spine: number): number { + // Checked *before* anything is mutated. A window that does not hold the + // chapter must leave the loaded one exactly as it was: swapping first and + // then reporting -1 replaces the reader's chapter with a different one and + // then tells the caller the jump failed, which is both wrong on screen and + // impossible for the caller to undo — the old sections are already gone. + const local = content.items.findIndex((item) => item.seq === spine); + if (local === -1) return -1; + // The offset comes from the group the response actually carries. A // response that omits `group` (a server that chose not to window, or a // hand-built one in a test) still has to answer correctly, so the offset @@ -144,7 +159,6 @@ export function createStagedDoc(options: StagedDocOptions): StagedDoc { path: item.href, }); } - const local = items.findIndex((item) => item.seq === spine); return local; }, windowOffset(): number { @@ -160,6 +174,62 @@ export function createStagedDoc(options: StagedDocOptions): StagedDoc { return doc; } +/** + * Swaps a document's loaded window, whichever way the document exposes it. + * + * Two method names exist in the codebase for one operation — `setWindow` on a + * client-built `StagedDoc`, `loadWindow` on the `StagedBook` interface — and the + * reader screen used to pick between them by checking which one *looked* present. + * It checked for `loadWindow`, the documents answer to `setWindow`, and the check + * therefore always failed: every jump out of the loaded window was a silent + * no-op. This function is the one place that knows both names, so no caller has + * to guess and a future third name has one place to be added. + * + * Returns the local index of `spine` in the newly loaded window, or -1 when there + * is no window to load or the window does not hold that spine. + */ +export function adoptWindow(doc: BookDoc, content: BookContent, spine: number): number { + const staged = doc as StagedBook & Partial; + if (typeof staged.setWindow === 'function') return staged.setWindow(content, spine); + if (typeof staged.loadWindow === 'function') return staged.loadWindow(content, spine); + return -1; +} + +/** + * Which window (group) holds a whole-book index. + * + * Derived from the manifest's own group sizes rather than from the constant the + * client keeps, because a format is free to window at its own granularity — a + * comic windows by volume, and a volume is not forty pages. The constant is only + * a fallback for a manifest that ships no groups at all. + * + * The result is the group's *index*, which is what the endpoint takes, not the + * group's `seq`. + */ +export function windowIndexOf(content: BookContent, spine: number): number { + const groups = content.groups ?? []; + if (groups.length > 0) { + let offset = groups[0]?.offset ?? 0; + for (const [index, group] of groups.entries()) { + // The first group may declare an offset of its own instead of zero; the + // running offset is anchored on it so both conventions answer correctly. + if (index > 0) offset += groups[index - 1]?.count ?? 0; + if (spine < offset + group.count) return index; + } + return groups.length - 1; + } + return Math.max(0, Math.floor(spine / CHAPTER_WINDOW)); +} + +/** + * Chapters per window, for a manifest that reports no groups of its own. + * + * Duplicated from the server's constant on purpose (see `CHAPTER_WINDOW` in the + * reader screen): overlapping by one window costs one request, never a wrong + * chapter, because the response is always the authority on what it contains. + */ +const CHAPTER_WINDOW = 40; + function formatFor(kind: BookContent['kind']): BookDoc['format'] { switch (kind) { case 'paged': diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index a1fc92c..ef70597 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -136,6 +136,41 @@ body { overflow: hidden; } +/* + * The stage's slot in the component tree. + * + * A wrapper rather than the stage itself, because the stage is owned by the + * screen and handed in through a ref callback (see `StageHost`): it is a node + * with its own lifetime, and putting it in a tree that re-renders is how a + * chapter's shadow root ends up detached from the element the paginator + * measured. The wrapper does not inherit `.stage`'s rules, so it has to carry + * the one that matters — *growing*. Without this the slot is a zero-height block + * in a flex column, and the reader sees the chrome, the header and the footer + * with no text between them. + */ +.stage-host { + display: flex; + flex: 1 1 auto; + min-height: 0; + position: relative; +} + +/* + * The stage fills its slot. + * + * `.stage` is `position: relative` with a percentage-free height, which is all it + * needs once its parent is a flex container — but the parent is `.stage-host` + * above, added when the chrome became a component tree, and a *block* parent + * gives an auto-height child its content height instead of the slot's. The stage + * then measures zero and the book is laid out in a band at the top of the + * screen. `flex: 1` here is what restores "the stage is the space between the + * chrome", which is the one thing this layout is supposed to guarantee. + */ +.stage-host > .stage { + flex: 1 1 auto; + min-height: 0; +} + /* * The chapter document is rendered into a shadow root so a book's own * stylesheet cannot reach the app chrome, and the app chrome cannot reach into @@ -165,46 +200,30 @@ body { } /* - * Reflowable content column. + * The reading column's own rules are in `ui/shadow.ts`, not here — and that + * separation is load-bearing. * - * `max-inline-size` on the *container* rather than on the book's own elements: - * it constrains the line length without editing a single author rule, which is - * the only way to bound measure without also overriding the author's margins on - * `p` and `div`. + * `.book-flow`, `.fixed-page`, `.reader-speech-highlight` and the page-turn + * animations all belong to elements inside the book's *shadow root*, and a + * selector in a document stylesheet does not reach into a shadow root. Declaring + * them here is therefore indistinguishable from deleting them: the column + * rendered at the browser default — no measure, no margins, no font scale, and + * no columns in paged mode — while the surrounding chrome, which *is* in the + * document, looked perfectly styled. That asymmetry is what made it read as a + * missing feature rather than a missing stylesheet. + * + * What stays below is everything that is genuinely in the document: the + * brightness overlay (a pseudo-element on the *host*, which is light DOM), the + * host element's own box, and the chrome. */ -.book-flow { - position: relative; - max-inline-size: var(--reader-measure); - margin-inline: auto; - padding-block: var(--reader-page-padding-block); - /* - * The margin control is a *variable* rather than a fixed padding, and the - * reader's value rides on the left/right pair only: the block padding is set by - * the shell because vertical whitespace on a phone is a different decision - * (safe areas) from the measure. - */ - padding-inline: var(--reader-page-margin); - /* The size control scales the root of the injected document only. */ - font-size: calc(1em * var(--reader-font-scale)); - line-height: var(--reader-line-height); - /* - * Both of these are set as inheriting values on the container, never as rules - * on the book's own elements. `inherit` is the default in both cases, so "off" - * means the author's typography is untouched — which is the whole contract of - * this stylesheet. Where the book ships an absolute font stack, this is prefixed - * rather than replaced: the book's stack stays first. - */ - font-family: var(--reader-font-family); - text-align: var(--reader-text-align); -} /* * Brightness. * * A dimming bed-time overlay rather than a change to the palette: the reader's * theme is the author's colours, and darkening them keeps the relationship - * between paper and ink. An overlay *on top* would darken the text as much as the - * paper, which is what makes a "brightness" slider feel broken. Applied with + * between paper and ink. An overlay *on top* would darken the text as much as + * the paper, which is what makes a "brightness" slider feel broken. Applied with * `mix-blend-mode: multiply` on a pseudo-element so it costs no extra DOM and * cannot be reached by the book. */ @@ -219,295 +238,15 @@ body { mix-blend-mode: multiply; } -.book-host[data-paginated='true'] .book-flow { - /* - * Paged mode: the column is exactly one viewport tall and overflows - * horizontally by column. This is the one place a structural override is - * unavoidable — CSS multi-column is the only way to paginate reflowable text - * without re-measuring every line in JS, and it needs the ancestor chain to - * honour a fixed height. - */ - height: 100%; - max-inline-size: none; - padding: 0; - column-gap: 0; - columns: 1; - column-width: 100vw; - column-fill: auto; - overflow: hidden; -} - -.book-host[data-paginated='true'] .book-flow > * { - /* A block that spans a column break would be split awkwardly; keeping - headings with their following paragraph is the cheap 90% fix. */ - break-inside: auto; -} - +/* + * The host is the scroll container in scroll mode and a plain frame in paged + * mode; `book-content` is an unknown element to the browser, so its `display` + * has to be stated. + */ book-content { display: block; } -/* ------------------------------------------------------------------ * - * Fixed-layout pages (comic, PDF fallback) - * ------------------------------------------------------------------ */ - -.fixed-page { - display: grid; - place-items: center; - width: 100%; - height: 100%; - /* - * `contain` keeps a 4000px-tall scan from forcing the whole layout to - * reflow on every page turn. - */ - contain: layout paint size; - background: #0d0d0f; -} - -.fixed-page img { - /* - * `max-*` rather than fixed dimensions: a comic page must letterbox inside - * the viewport, and `object-fit: contain` is the only way to do that without - * knowing the image's intrinsic size before it loads. - */ - max-width: 100%; - max-height: 100%; - width: auto; - height: auto; - object-fit: contain; - display: block; - /* Prevents the 1px dark seam some Android GPUs render along a scaled edge. */ - image-rendering: auto; - outline: none; - -webkit-user-drag: none; - user-select: none; -} - -.fixed-page[data-fit='width'] img { - max-height: none; - width: 100%; -} - -/* ------------------------------------------------------------------ * - * PDF - * ------------------------------------------------------------------ */ - -.pdf-frame { - width: 100%; - height: 100%; - border: 0; - background: #0d0d0f; -} - -/* ------------------------------------------------------------------ * - * Injected book content: the minimum set of overrides - * ------------------------------------------------------------------ * - * - * Everything below is scoped to `book-content`, so it reaches the book's own - * markup and nothing else. Each rule states why it is necessary. A rule without - * a justification does not belong in this section. - */ - -book-content img, -book-content svg, -book-content video { - /* - * A book laid out for a print page has images sized in absolute units. On a - * 360px screen those overflow and force a horizontal scrollbar that makes the - * whole chapter feel broken. Bounding them is an override, but the author's - * value is honoured up to the viewport, so nothing is enlarged. - */ - max-width: 100%; - height: auto; -} - -book-content table { - /* - * Tables are the one construct that genuinely cannot reflow into a narrow - * column. Allowing a horizontal scroll inside the table is strictly better - * than letting it push the chapter wider than the screen. - */ - max-width: 100%; - overflow-x: auto; - display: block; -} - -book-content pre { - /* - * `pre` does not wrap by default, so a long code line or an ASCII-art page - * makes the entire chapter scroll horizontally. Wrapping keeps the chapter - * usable; the author's font and spacing are untouched. - */ - white-space: pre-wrap; - overflow-wrap: break-word; - max-width: 100%; -} - -book-content a[href] { - /* - * Footnotes and cross-references must be tappable, and the platform default - * is a colour the author already chose. Only the touch target is enforced. - */ - -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); - touch-action: manipulation; -} - -book-content :is(h1, h2, h3, h4, h5, h6) { - /* - * Long Chinese headings have no spaces and will overflow rather than wrap. - * `overflow-wrap` only affects the overflowing case, so a heading that fits is - * rendered exactly as the author specified. - */ - overflow-wrap: break-word; -} - -book-content ruby rt { - /* - * 振假名 (ruby annotations) are named in the product design as a - * differentiator (§9). Sizing them correctly relative to the base text is what - * makes them readable on a phone; the default 50% is too small at 16px. - */ - font-size: 0.55em; - line-height: 1; -} - -book-content :is(ruby, rt) { - /* Ruby is a first-class text feature here, so it must not be hidden or - clipped by an ancestor's overflow or line-height. */ - line-height: 1.6; -} - -book-content [epub|type~='pagebreak'], -book-content hr { - /* - * Some publishers put a decorative rule at every page break, which turns into - * a stray line in the middle of a paragraph once pagination is redone. - * Collapsing it to a spacing hint keeps the author's intent without the - * artefact. `hr` is intentionally NOT hidden: a rule the author drew to - * separate scenes is content. - */ - border: 0; -} - -book-content figure, -book-content blockquote { - /* - * Print-oriented books indent these with fixed margins that waste a third of - * a phone screen. The author's indentation is preserved, only the outer - * margin is capped to the container. - */ - max-width: 100%; - margin-inline: auto; -} - -/* ------------------------------------------------------------------ * - * Selection - * ------------------------------------------------------------------ */ - -book-content ::selection { - background: color-mix(in srgb, var(--reader-accent) 35%, transparent); - color: inherit; -} - -/* ------------------------------------------------------------------ * - * Page-turn transition - * ------------------------------------------------------------------ */ - -.book-host[data-animating='slide-next'] .book-flow { - animation: slide-next 180ms ease-out; -} - -.book-host[data-animating='slide-previous'] .book-flow { - animation: slide-previous 180ms ease-out; -} - -.book-host[data-animating='fade-next'] .book-flow { - animation: fade-next 160ms ease-out; -} - -.book-host[data-animating='fade-previous'] .book-flow { - animation: fade-previous 160ms ease-out; -} - -@keyframes slide-next { - from { - transform: translateX(6%); - opacity: 0.6; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -@keyframes slide-previous { - from { - transform: translateX(-6%); - opacity: 0.6; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -@keyframes fade-next { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fade-previous { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@media (prefers-reduced-motion: reduce) { - .book-host[data-animating] .book-flow { - animation: none; - } -} - -/* ------------------------------------------------------------------ * - * Read-aloud helpers - * ------------------------------------------------------------------ */ - -/* - * The sentence being spoken. - * - * Drawn as a translucent block *behind* the text, positioned from a `Range`'s - * client rect. The alternative — wrapping the sentence in a `` — would edit - * the publisher's markup and, worse, change the line box metrics for that phrase, - * which repaginates the chapter the moment playback starts. - */ -.reader-speech-highlight { - position: absolute; - pointer-events: none; - z-index: -1; - border-radius: 3px; - background: color-mix(in srgb, var(--reader-accent) 34%, transparent); - transition: top 120ms linear, left 120ms linear, width 120ms linear, height 120ms linear; -} - -/* The overlay needs a stacking context that puts it under the text but above the - page background; `z-index: -1` alone would fall behind an opaque body. */ -.book-flow { - isolation: isolate; -} - -@media (prefers-reduced-motion: reduce) { - .reader-speech-highlight { - transition: none; - } -} - /* ------------------------------------------------------------------ * * Chrome: header, shelf, panels * ------------------------------------------------------------------ */ @@ -1487,10 +1226,53 @@ book-content ::selection { .footer-row .chapter { flex: 1 1 auto; + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; + /* The chapter label is the second way into the contents panel, because the ☰ + button beside it is a 2.5rem square and the label is the thing a reader + actually aims at when they want to pick a chapter. */ + border: 0; + background: transparent; + color: inherit; + font: inherit; + padding: 0.35rem 0.25rem; + border-radius: var(--reader-radius); + cursor: pointer; +} + +.footer-row .chapter:active { + background: var(--reader-line); +} + +/* + * The chapter buttons share the row with the chapter label, so they are a size + * down from the topbar's icon buttons: four 2.5rem controls plus a label is more + * than a 360px phone has, and the two that turn pages deserve the room. + */ +.footer-row .icon-button { + width: 2.1rem; + height: 2.1rem; + font-size: 1rem; +} + +.footer-row .icon-button:disabled { + opacity: 0.35; + cursor: default; +} + +.footer-meta span { + flex: 1 1 0; +} + +.footer-meta span:first-child { + text-align: start; +} + +.footer-meta .progress-label { + text-align: end; } /* ---- read-aloud bar ---- */ diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index fe3360a..08e1fc9 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -20,6 +20,21 @@ import type { SpeechEngineKind } from '../render/speech.ts'; import { type ComponentChildren, type JSX } from './vendor/preact.ts'; import { IconButton, SectionTitle } from './toolkit.tsx'; +/** + * A table-of-contents row. + * + * `spine` is the whole-book position, and its absence is meaningful: the server + * omits it for formats whose navigation is not a linear chapter list. A row + * without one cannot be jumped to across windows, so the panel marks it instead + * of offering a tap that would do nothing. + */ +export interface ChromeTocEntry { + id: string; + label: string; + depth: number; + spine?: number; +} + export interface SpeechBarState { active: boolean; state: string; @@ -43,8 +58,16 @@ export interface ChromeState { chapterLabel: string; tocOpen: boolean; settingsOpen: boolean; - toc: Array<{ id: string; label: string; depth: number }>; + toc: ChromeTocEntry[]; currentSectionId: string; + /** 1-based position of the current page inside its chapter, and the count. */ + pageInChapter: number; + chapterPages: number; + /** Which whole-book chapter is open, 1-based, and how many there are. */ + chapterIndex: number; + chapterCount: number; + /** True while a window is being fetched, so the chapter buttons can say so. */ + navigating: boolean; tts: SpeechBarState; layout: string; format: string; @@ -90,6 +113,7 @@ export interface ChromeHandlers { onSpeakFromHere(): void; onSwitchEngine(kind: AppSettings['ttsEngine']): void; onTocEntry(ref: string): void; + onChapter(delta: 1 | -1): void; onTurnPage(direction: 'next' | 'previous'): void; onSpeechToggle(): void; onSpeechPrevious(): void; @@ -142,15 +166,37 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX ☰ - {state.chapterLabel} + handlers.onChapter(-1)} + > + ⟨ + + + = state.chapterCount} + onClick={() => handlers.onChapter(1)} + > + ⟩ + ⚙ -
- - {percentOf(state.progress)} - +
+ + {state.chapterCount > 0 ? `${state.chapterIndex}/${state.chapterCount} 章` : ''} + + + {state.chapterPages > 0 + ? `本章 ${state.pageInChapter}/${state.chapterPages} 页` + : ''} + + {percentOf(state.progress)}
@@ -167,6 +213,10 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX