fix(reader): open TXT files shared via "Open with" (#4571)

* fix(reader): open TXT files shared via "Open with" by converting to EPUB

The Android "Open with Readest" (VIEW intent) transient path hands the
reader the original .txt file (its filePath points at the content:// URI),
unlike the managed library which stores the already-converted EPUB. The
DocumentLoader had no branch for a raw .txt, so open() returned
{ book: null } and initViewState crashed with
"TypeError: Cannot read properties of null (reading 'metadata')",
leaving the user stuck on the library splash.

Add an isTxt() check that converts the raw .txt to EPUB in-memory (the
same TxtToEpubConverter the import path runs) and parses that. The
converter emits a .epub-named file, so the importer's own
DocumentLoader.open() on the converted file is unaffected.

Verified on-device (emulator, warm + cold start): the TXT now opens and
renders in the reader instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): allow adjusting highlight opacity in e-ink mode

Drop the isEink prop that disabled the highlight Opacity slider under
e-ink. Opacity is still meaningful on e-ink, so let users change it.
Removes the prop from HighlightColorsEditor, its ColorPanel call site,
and the test render helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(send): mock fetch to fix flaky article conversion test

The article/page conversion paths fetch a favicon + author image for the
synthetic cover via globalThis.fetch. In jsdom that hit the real network:
a live fetch to the sample URL can hang up to faviconFetcher's 6s timeout,
exceeding the 5s test timeout and intermittently failing the suite. Stub
fetch so the cover falls back to its initial-letter tile (the pattern other
tests in this suite already use). Article test: ~5003ms hang -> ~80ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(agent): update annotation-share-toolbar memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-14 00:07:28 +08:00
committed by GitHub
parent 67c22c770b
commit 4b0bbc77b0
7 changed files with 79 additions and 13 deletions
@@ -7,8 +7,8 @@ metadata:
originSessionId: 507a0166-cb55-4f33-b633-3230c0c514ff
---
#4014 — added a native "Share" tool to the in-reader text-selection toolbar plus a
drag-and-drop customizer (show/hide + reorder tools). Branch
#4014 (PR #4570) — added a native "Share" tool to the in-reader text-selection toolbar
plus a drag-and-drop customizer (show/hide + reorder tools). Branch
`feat/annotation-share-toolbar-4014`; spec + plan in
`docs/superpowers/{specs,plans}/2026-06-13-annotation-share-toolbar*`.
@@ -33,10 +33,32 @@ Key facts / gotchas:
dedup, drop-unknown), `add/remove/reorderToolbar`. `ALL_ANNOTATION_TOOL_TYPES` is
asserted to match the `annotationToolButtons` registry order by a test.
- **Customizer** = `src/components/settings/AnnotationToolbarCustomizer.tsx`, a sub-page
off `ControlPanel` (Behavior panel) via `NavigationRow`, two `@dnd-kit` zones (pattern
copied from `CustomDictionaries.tsx`). Chips are tap-to-toggle AND drag (e-ink/keyboard
a11y, since no KeyboardSensor). Cross-platform guard: when editing on a `!canShare`
device, `persist` re-appends a `share` that was synced-in but hidden, so it isn't
dropped for the user's share-capable devices.
off `ControlPanel` (Behavior panel) via `NavigationRow`. Two `@dnd-kit` zones; chips are
tap-to-toggle AND drag. Design evolved heavily during live browser testing (see gotchas):
- **WYSIWYG**: "In toolbar" renders a faithful preview of the real selection popup —
`selection-popup bg-gray-600 text-white`, icon-only 32×32 buttons (mirrors
`AnnotationToolButton`), `w-fit max-w-full` (content-width, start-aligned). "Available"
tools are labeled icon+text chips. Zone content uses `px-4` to align with `SubPageHeader`.
- **dnd-kit multiple-containers pattern** (NOT the simple single-list one): single
`{toolbar, available}` state; `onDragOver` live-reparents across zones; custom
`collisionDetection` = `pointerWithin``rectIntersection` fallback, snapping a zone-id
hit to the closest inner chip (plain `closestCorners`/`closestCenter` CANNOT drop into an
empty zone). `rectSortingStrategy` (NOT `horizontalListSortingStrategy`, which breaks
wrapped layouts).
- **NO `DragOverlay`** — the settings modal is a CSS-`transform` container, so a
`position:fixed` overlay is offset from the cursor. In-place `useSortable` transform
(relative translate) tracks correctly.
- **`itemsRef` stale-closure fix**: dnd-kit calls `onDragEnd` with the handler captured at
drag START, so the closed-over `items` is stale → a cross-zone drag would bounce back on
release. Read live state from `itemsRef.current` in `handleDragEnd`/tap handlers.
- **Add all** (rebuilds in canonical `ALL_ANNOTATION_TOOL_TYPES` order, NOT prior order) /
**Clear all** header buttons.
- Cross-platform guard: when editing on a `!canShare` device, `persist` re-appends a
`share` that was synced-in but hidden, so it isn't dropped for share-capable devices.
- **Empty toolbar suppresses the popup**: when `getToolbarToolTypes` yields [] (user cleared
all), `Annotator.tsx` does NOT render the `AnnotationPopup` on a plain selection (gated on
`toolButtons.length > 0 || highlightOptionsVisible || annotationNotes.length > 0`) — no
empty bar, but highlight-edit/notes popups still work. (Earlier tried fallback-to-default;
user wanted full suppression instead.)
- Adding a tool to the union (`AnnotationToolType`) is compile-checked: the
`createAnnotationToolButtons` generic in `AnnotationTools.tsx` requires every member.
@@ -67,7 +67,6 @@ const Harness: React.FC<{ initialUserColors: UserHighlightColor[] }> = ({ initia
userHighlightColors={userColors}
defaultHighlightLabels={labels}
highlightOpacity={0.3}
isEink={false}
onCustomHighlightColorsChange={setCustomColors}
onUserHighlightColorsChange={setUserColors}
onDefaultHighlightLabelsChange={setLabels}
@@ -47,4 +47,27 @@ describe('DocumentLoader.open', () => {
expect(result.book).toBeTruthy();
expect(result.format).toBe('EPUB');
}, 15000);
it('opens a raw .txt by converting it to EPUB in-memory', async () => {
// The Android "Open with Readest" (VIEW intent) transient path hands the
// reader the original .txt file (its filePath points at the content:// URI),
// unlike the managed library which stores the already-converted EPUB. The
// loader must therefore be able to open a raw .txt directly; otherwise it
// returns { book: null } and initViewState crashes on `bookDoc.metadata`.
const txt = [
'Chapter 1',
'',
'It was a bright cold day in April, and the clocks were striking thirteen.',
'',
'Chapter 2',
'',
'Winston Smith slipped quickly through the glass doors of Victory Mansions.',
].join('\n');
const file = new File([txt], 'my-book.txt', { type: 'text/plain' });
const result = await new DocumentLoader(file).open();
expect(result.book).toBeTruthy();
expect(result.format).toBe('EPUB');
}, 15000);
});
@@ -1,10 +1,22 @@
import { describe, test, expect } from 'vitest';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { sanitizeHtml } from '@/utils/sanitize';
import { convertToEpub, isConvertible, mimeToKind } from '@/services/send/conversion/convertToEpub';
import { ConversionError } from '@/services/send/conversion/types';
const encode = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer as ArrayBuffer;
// The `article`/`page` conversion paths fetch a favicon + author image for the
// synthetic cover. A unit test must not hit the real network: a live fetch to
// the sample URLs can hang up to faviconFetcher's 6s timeout, which exceeds the
// 5s test timeout and makes the suite intermittently fail. Fail fast instead so
// the cover falls back to its initial-letter tile.
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network disabled in tests')));
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('isConvertible / mimeToKind', () => {
test('recognizes convertible MIME types', () => {
expect(isConvertible('text/html')).toBe(true);
@@ -380,7 +380,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
userHighlightColors={userHighlightColors}
defaultHighlightLabels={defaultHighlightLabels}
highlightOpacity={highlightOpacity}
isEink={viewSettings.isEink}
onCustomHighlightColorsChange={handleCustomHighlightColorsChange}
onUserHighlightColorsChange={handleUserHighlightColorsChange}
onDefaultHighlightLabelsChange={handleDefaultHighlightLabelsChange}
@@ -19,7 +19,6 @@ interface HighlightColorsEditorProps {
userHighlightColors: UserHighlightColor[];
defaultHighlightLabels: Partial<Record<DefaultHighlightColor, string>>;
highlightOpacity: number;
isEink: boolean;
onCustomHighlightColorsChange: (colors: Record<HighlightColor, string>) => void;
onUserHighlightColorsChange: (colors: UserHighlightColor[]) => void;
onDefaultHighlightLabelsChange: (labels: Partial<Record<DefaultHighlightColor, string>>) => void;
@@ -122,7 +121,6 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
userHighlightColors,
defaultHighlightLabels,
highlightOpacity,
isEink,
onCustomHighlightColorsChange,
onUserHighlightColorsChange,
onDefaultHighlightLabelsChange,
@@ -276,7 +274,6 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
label={_('Opacity')}
value={highlightOpacity}
onChange={onOpacityChange}
disabled={isEink}
min={0.1}
max={1}
step={0.1}
+14
View File
@@ -327,6 +327,10 @@ export class DocumentLoader {
);
}
private isTxt(): boolean {
return this.file.type === 'text/plain' || this.file.name.endsWith(`.${EXTS.TXT}`);
}
public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
let book = null;
let format: BookFormat = 'EPUB';
@@ -334,6 +338,16 @@ export class DocumentLoader {
throw new Error('File is empty');
}
try {
// A raw .txt has no binary book format, so the checks below all miss and
// `book` stays null. Convert it to EPUB in-memory first (the same
// conversion the import path runs) and parse that. The managed library
// stores the already-converted EPUB, but the Android "Open with" transient
// path points the book at the original .txt, so it reaches us unconverted.
if (this.isTxt()) {
const { TxtToEpubConverter } = await import('@/utils/txt');
const { file: epubFile } = await new TxtToEpubConverter().convert({ file: this.file });
return await new DocumentLoader(epubFile).open();
}
if (await this.isZip()) {
// EPUB-only fast path: ask Rust to pre-read OPF/nav/ncx + sizes.
// CBZ/FBZ skip this -- they have no OPF and Rust has no parser