diff --git a/apps/readest-app/.claude/memory/annotation-share-toolbar-4014.md b/apps/readest-app/.claude/memory/annotation-share-toolbar-4014.md index 04b51915..1ea1123a 100644 --- a/apps/readest-app/.claude/memory/annotation-share-toolbar-4014.md +++ b/apps/readest-app/.claude/memory/annotation-share-toolbar-4014.md @@ -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. diff --git a/apps/readest-app/src/__tests__/components/HighlightColorsEditor.test.tsx b/apps/readest-app/src/__tests__/components/HighlightColorsEditor.test.tsx index ba7a267d..4e42fa57 100644 --- a/apps/readest-app/src/__tests__/components/HighlightColorsEditor.test.tsx +++ b/apps/readest-app/src/__tests__/components/HighlightColorsEditor.test.tsx @@ -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} diff --git a/apps/readest-app/src/__tests__/libs/document.test.ts b/apps/readest-app/src/__tests__/libs/document.test.ts index adb193f6..4aeefe45 100644 --- a/apps/readest-app/src/__tests__/libs/document.test.ts +++ b/apps/readest-app/src/__tests__/libs/document.test.ts @@ -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); }); diff --git a/apps/readest-app/src/__tests__/services/send-conversion.test.ts b/apps/readest-app/src/__tests__/services/send-conversion.test.ts index e7709e23..ca924900 100644 --- a/apps/readest-app/src/__tests__/services/send-conversion.test.ts +++ b/apps/readest-app/src/__tests__/services/send-conversion.test.ts @@ -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); diff --git a/apps/readest-app/src/components/settings/ColorPanel.tsx b/apps/readest-app/src/components/settings/ColorPanel.tsx index 0fa7d5bd..58ac95a7 100644 --- a/apps/readest-app/src/components/settings/ColorPanel.tsx +++ b/apps/readest-app/src/components/settings/ColorPanel.tsx @@ -380,7 +380,6 @@ const ColorPanel: React.FC = ({ bookKey, onRegisterReset userHighlightColors={userHighlightColors} defaultHighlightLabels={defaultHighlightLabels} highlightOpacity={highlightOpacity} - isEink={viewSettings.isEink} onCustomHighlightColorsChange={handleCustomHighlightColorsChange} onUserHighlightColorsChange={handleUserHighlightColorsChange} onDefaultHighlightLabelsChange={handleDefaultHighlightLabelsChange} diff --git a/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx b/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx index 4180e4a9..6ee98352 100644 --- a/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx +++ b/apps/readest-app/src/components/settings/color/HighlightColorsEditor.tsx @@ -19,7 +19,6 @@ interface HighlightColorsEditorProps { userHighlightColors: UserHighlightColor[]; defaultHighlightLabels: Partial>; highlightOpacity: number; - isEink: boolean; onCustomHighlightColorsChange: (colors: Record) => void; onUserHighlightColorsChange: (colors: UserHighlightColor[]) => void; onDefaultHighlightLabelsChange: (labels: Partial>) => void; @@ -122,7 +121,6 @@ const HighlightColorsEditor: React.FC = ({ userHighlightColors, defaultHighlightLabels, highlightOpacity, - isEink, onCustomHighlightColorsChange, onUserHighlightColorsChange, onDefaultHighlightLabelsChange, @@ -276,7 +274,6 @@ const HighlightColorsEditor: React.FC = ({ label={_('Opacity')} value={highlightOpacity} onChange={onOpacityChange} - disabled={isEink} min={0.1} max={1} step={0.1} diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index 18415f52..174471db 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -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