From 67c22c770b8bb67ddd70808d996e1f92230a5bed Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 13 Jun 2026 20:11:59 +0800 Subject: [PATCH] feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(spec): annotation Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) * docs(plan): implementation plan for Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(annotator): add 'share' annotation tool type and button (#4014) * feat(annotator): add pure toolbar order/visibility helpers (#4014) * feat(annotator): add annotationToolbarItems view setting (#4014) * feat(annotator): add shareSelectedText ladder helper (#4014) Co-Authored-By: Claude Sonnet 4.6 * feat(annotator): render Share tool and honor toolbar order in selection popup (#4014) Co-Authored-By: Claude Sonnet 4.6 * feat(settings): add drag-and-drop annotation toolbar customizer (#4014) Co-Authored-By: Claude Sonnet 4.6 * feat(settings): open the toolbar customizer from the Behavior panel (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) * chore(i18n): extract and translate annotation share/toolbar strings (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014) Addresses final-review findings: de-duplicate the triplicated canShare definition into share.ts::canShareText, trim ShareCapableService to the fields actually read, and stop the toolbar customizer from dropping a synced 'share' tool when edited on a non-share-capable device. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014) Rework the customizer per live testing: - Render 'In toolbar' as a faithful, content-width, start-aligned preview of the real selection popup (gray bar, icon-only buttons); 'Available' tools show as labeled chips. - Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a transformed modal offsets), pointerWithin collision so empty zones accept drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start handler-capture stale closure. - Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts. - Align zone labels with the SubPageHeader breadcrumb. - Empty toolbar now suppresses the selection popup entirely (no empty bar), while still allowing highlight-edit/notes popups. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(annotator): size selection popup to visible tool count (#4014) With the customizable toolbar a fixed-width popup looked sparse for a 2-3 tool toolbar (buttons spread to the corners). Size the popup to the number of visible tools (responsive) capped at the previous max; annotated selections keep the max width since they show highlight options / notes. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) * test(annotator): render default tools (not Share) in popup layout screenshot (#4014) The visual regression test rendered every annotationToolButtons entry, so adding the Share tool shifted the toolbar to 9 buttons and broke the baselines. Share is hidden by default (added via Customize Toolbar), so the popup screenshot should mirror the default-enabled set — filter to DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../2026-06-13-annotation-share-toolbar.md | 1091 +++++++++++++++++ ...6-06-13-annotation-share-toolbar-design.md | 177 +++ .../public/locales/ar/translation.json | 12 +- .../public/locales/bn/translation.json | 12 +- .../public/locales/bo/translation.json | 12 +- .../public/locales/de/translation.json | 12 +- .../public/locales/el/translation.json | 12 +- .../public/locales/es/translation.json | 12 +- .../public/locales/fa/translation.json | 12 +- .../public/locales/fr/translation.json | 12 +- .../public/locales/he/translation.json | 12 +- .../public/locales/hi/translation.json | 12 +- .../public/locales/hu/translation.json | 12 +- .../public/locales/id/translation.json | 12 +- .../public/locales/it/translation.json | 12 +- .../public/locales/ja/translation.json | 12 +- .../public/locales/ko/translation.json | 12 +- .../public/locales/ms/translation.json | 12 +- .../public/locales/nl/translation.json | 12 +- .../public/locales/pl/translation.json | 12 +- .../public/locales/pt-BR/translation.json | 12 +- .../public/locales/pt/translation.json | 12 +- .../public/locales/ro/translation.json | 12 +- .../public/locales/ru/translation.json | 12 +- .../public/locales/si/translation.json | 12 +- .../public/locales/sl/translation.json | 12 +- .../public/locales/sv/translation.json | 12 +- .../public/locales/ta/translation.json | 12 +- .../public/locales/th/translation.json | 12 +- .../public/locales/tr/translation.json | 12 +- .../public/locales/uk/translation.json | 12 +- .../public/locales/uz/translation.json | 12 +- .../public/locales/vi/translation.json | 12 +- .../public/locales/zh-CN/translation.json | 12 +- .../public/locales/zh-TW/translation.json | 12 +- .../annotation-popup-layout.browser.test.tsx | 15 +- .../src/__tests__/services/constants.test.ts | 8 + .../__tests__/utils/annotationToolbar.test.ts | 85 ++ .../__tests__/utils/shareSelectedText.test.ts | 113 ++ .../components/annotator/AnnotationTools.tsx | 8 + .../reader/components/annotator/Annotator.tsx | 115 +- .../settings/AnnotationToolbarCustomizer.tsx | 364 ++++++ .../src/components/settings/ControlPanel.tsx | 45 +- apps/readest-app/src/services/constants.ts | 2 + apps/readest-app/src/types/annotator.ts | 3 +- apps/readest-app/src/types/book.ts | 1 + .../src/utils/annotationToolbar.ts | 93 ++ apps/readest-app/src/utils/share.ts | 64 + 48 files changed, 2496 insertions(+), 84 deletions(-) create mode 100644 apps/readest-app/docs/superpowers/plans/2026-06-13-annotation-share-toolbar.md create mode 100644 apps/readest-app/docs/superpowers/specs/2026-06-13-annotation-share-toolbar-design.md create mode 100644 apps/readest-app/src/__tests__/utils/annotationToolbar.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts create mode 100644 apps/readest-app/src/components/settings/AnnotationToolbarCustomizer.tsx create mode 100644 apps/readest-app/src/utils/annotationToolbar.ts diff --git a/apps/readest-app/docs/superpowers/plans/2026-06-13-annotation-share-toolbar.md b/apps/readest-app/docs/superpowers/plans/2026-06-13-annotation-share-toolbar.md new file mode 100644 index 00000000..7938266f --- /dev/null +++ b/apps/readest-app/docs/superpowers/plans/2026-06-13-annotation-share-toolbar.md @@ -0,0 +1,1091 @@ +# Annotation Share Tool + Customizable Toolbar Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a native "Share" tool to the text-selection toolbar (#4014) and let users show/hide and reorder annotation tools via a drag-and-drop sub-page in Settings → Behavior. + +**Architecture:** Pure helpers in `src/utils/annotationToolbar.ts` own the visible/available split + reorder logic (unit-tested); `src/utils/share.ts` owns the sharekit → `navigator.share` → clipboard ladder (unit-tested). `Annotator.tsx` filters/orders its toolbar buttons through `getToolbarToolTypes` and adds `handleShare`. A new `AnnotationToolbarCustomizer` sub-page (reached from `ControlPanel`) renders the real tool buttons in two `@dnd-kit` zones. The visible order is persisted as a new view setting `annotationToolbarItems`. + +**Tech Stack:** Next.js + React + TypeScript, Zustand view settings, `@dnd-kit/core` + `/sortable`, `@choochmeque/tauri-plugin-sharekit-api`, Vitest + jsdom, Biome + tsgo. + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `src/types/annotator.ts` | `AnnotationToolType` union | Modify — add `'share'` | +| `src/types/book.ts` | `AnnotatorConfig` | Modify — add `annotationToolbarItems` | +| `src/utils/annotationToolbar.ts` | Pure toolbar order/visibility helpers + default list | Create | +| `src/utils/share.ts` | Share-text ladder helper | Create | +| `src/services/constants.ts` | `DEFAULT_ANNOTATOR_CONFIG` | Modify — add default items | +| `src/app/reader/components/annotator/AnnotationTools.tsx` | Tool button registry | Modify — add `share` | +| `src/app/reader/components/annotator/Annotator.tsx` | Live toolbar + handlers | Modify — `handleShare`, `canShare`, filtered `toolButtons`, quick-action | +| `src/components/settings/AnnotationToolbarCustomizer.tsx` | DnD customizer sub-page | Create | +| `src/components/settings/ControlPanel.tsx` | Behavior panel | Modify — `NavigationRow` + sub-page + reset | +| `src/__tests__/utils/annotationToolbar.test.ts` | Helper tests | Create | +| `src/__tests__/utils/share.test.ts` | Share-ladder tests | Create | +| `src/__tests__/services/constants.test.ts` | Defaults assertion | Modify | + +All commands run from `apps/readest-app` in the worktree `/Users/chrox/dev/readest-feat-annotation-share-toolbar-4014`. + +--- + +## Task 1: Add the `share` tool type and button + +**Files:** +- Modify: `src/types/annotator.ts` +- Modify: `src/app/reader/components/annotator/AnnotationTools.tsx` + +- [ ] **Step 1: Add `'share'` to the union** + +In `src/types/annotator.ts`, change the union to: + +```typescript +export type AnnotationToolType = + | 'copy' + | 'highlight' + | 'annotate' + | 'search' + | 'dictionary' + | 'translate' + | 'tts' + | 'proofread' + | 'share'; +``` + +- [ ] **Step 2: Add the share icon import** + +In `src/app/reader/components/annotator/AnnotationTools.tsx`, add to the existing `react-icons/fi` imports block (it already imports `FiSearch`, `FiCopy`): + +```typescript +import { FiShare } from 'react-icons/fi'; +``` + +- [ ] **Step 3: Add the `share` button entry** + +In the same file, append a new entry to the `createAnnotationToolButtons([...])` array, after the `proofread` entry (keep it last so the canonical order matches `ALL_ANNOTATION_TOOL_TYPES` in Task 2): + +```typescript + { + type: 'share', + label: _('Share'), + tooltip: _('Share text after selection'), + Icon: FiShare, + quickAction: true, + }, +``` + +- [ ] **Step 4: Type-check** + +Run: `pnpm exec tsgo --noEmit` (or `pnpm lint`) +Expected: no errors. (The `createAnnotationToolButtons` generic now requires every union member, including `share`, to be present — confirming completeness.) + +- [ ] **Step 5: Commit** + +```bash +git add src/types/annotator.ts src/app/reader/components/annotator/AnnotationTools.tsx +git commit -m "feat(annotator): add 'share' annotation tool type and button (#4014)" +``` + +--- + +## Task 2: Pure toolbar helpers + tests + +**Files:** +- Create: `src/utils/annotationToolbar.ts` +- Create: `src/__tests__/utils/annotationToolbar.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/__tests__/utils/annotationToolbar.test.ts`: + +```typescript +import { describe, test, expect } from 'vitest'; +import { annotationToolButtons } from '@/app/reader/components/annotator/AnnotationTools'; +import { + ALL_ANNOTATION_TOOL_TYPES, + DEFAULT_ANNOTATION_TOOLBAR_ITEMS, + getToolbarToolTypes, + getAvailableToolTypes, + addToolToToolbar, + removeToolFromToolbar, + reorderToolbar, +} from '@/utils/annotationToolbar'; + +describe('annotationToolbar helpers', () => { + test('ALL_ANNOTATION_TOOL_TYPES matches the button registry order', () => { + expect(ALL_ANNOTATION_TOOL_TYPES).toEqual(annotationToolButtons.map((b) => b.type)); + }); + + test('default toolbar is the eight non-share tools in canonical order', () => { + expect(DEFAULT_ANNOTATION_TOOLBAR_ITEMS).toEqual([ + 'copy', + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', + ]); + expect(DEFAULT_ANNOTATION_TOOLBAR_ITEMS).not.toContain('share'); + }); + + test('getToolbarToolTypes preserves order and falls back to default when undefined', () => { + expect(getToolbarToolTypes(undefined, true)).toEqual(DEFAULT_ANNOTATION_TOOLBAR_ITEMS); + expect(getToolbarToolTypes(['search', 'copy'], true)).toEqual(['search', 'copy']); + }); + + test('getToolbarToolTypes drops share when !canShare, keeps it when canShare', () => { + expect(getToolbarToolTypes(['copy', 'share'], false)).toEqual(['copy']); + expect(getToolbarToolTypes(['copy', 'share'], true)).toEqual(['copy', 'share']); + }); + + test('getToolbarToolTypes drops unknown/duplicate entries', () => { + expect(getToolbarToolTypes(['copy', 'copy', 'bogus' as never], true)).toEqual(['copy']); + }); + + test('getAvailableToolTypes returns canonical-order complement', () => { + expect(getAvailableToolTypes(['copy'], true)).toEqual([ + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', + 'share', + ]); + }); + + test('getAvailableToolTypes hides share when !canShare', () => { + expect(getAvailableToolTypes(['copy'], false)).not.toContain('share'); + }); + + test('addToolToToolbar appends by default and is a no-op when present', () => { + expect(addToolToToolbar(['copy'], 'share')).toEqual(['copy', 'share']); + expect(addToolToToolbar(['copy', 'share'], 'share')).toEqual(['copy', 'share']); + }); + + test('addToolToToolbar inserts at the given index', () => { + expect(addToolToToolbar(['copy', 'search'], 'share', 1)).toEqual(['copy', 'share', 'search']); + }); + + test('removeToolFromToolbar removes the tool', () => { + expect(removeToolFromToolbar(['copy', 'share'], 'share')).toEqual(['copy']); + expect(removeToolFromToolbar(['copy'], 'share')).toEqual(['copy']); + }); + + test('reorderToolbar moves a tool to another tool position', () => { + expect(reorderToolbar(['copy', 'highlight', 'search'], 'search', 'copy')).toEqual([ + 'search', + 'copy', + 'highlight', + ]); + expect(reorderToolbar(['copy', 'search'], 'copy', 'copy')).toEqual(['copy', 'search']); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/__tests__/utils/annotationToolbar.test.ts` +Expected: FAIL — `Cannot find module '@/utils/annotationToolbar'`. + +- [ ] **Step 3: Implement the helpers** + +Create `src/utils/annotationToolbar.ts`: + +```typescript +import type { AnnotationToolType } from '@/types/annotator'; + +// Canonical order of every annotation tool. Kept in sync with +// `annotationToolButtons` in AnnotationTools.tsx (asserted by a unit test). +export const ALL_ANNOTATION_TOOL_TYPES: AnnotationToolType[] = [ + 'copy', + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', + 'share', +]; + +// Default toolbar: the eight pre-existing tools in their original order. +// 'share' starts hidden in the Available tray per the #4014 design. +export const DEFAULT_ANNOTATION_TOOLBAR_ITEMS: AnnotationToolType[] = [ + 'copy', + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', +]; + +// Drop unknown/duplicate entries; fall back to the default when unset (a +// pre-existing per-book config may not carry the field yet). +const sanitize = (items: AnnotationToolType[] | undefined): AnnotationToolType[] => { + const source = items ?? DEFAULT_ANNOTATION_TOOLBAR_ITEMS; + const seen = new Set(); + const out: AnnotationToolType[] = []; + for (const type of source) { + if (ALL_ANNOTATION_TOOL_TYPES.includes(type) && !seen.has(type)) { + seen.add(type); + out.push(type); + } + } + return out; +}; + +// Visible tools to render in the live selection toolbar, in order. +export const getToolbarToolTypes = ( + items: AnnotationToolType[] | undefined, + canShare: boolean, +): AnnotationToolType[] => sanitize(items).filter((type) => canShare || type !== 'share'); + +// Hidden tools (the "Available" tray), in canonical order. +export const getAvailableToolTypes = ( + items: AnnotationToolType[] | undefined, + canShare: boolean, +): AnnotationToolType[] => { + const visible = new Set(sanitize(items)); + return ALL_ANNOTATION_TOOL_TYPES.filter( + (type) => !visible.has(type) && (canShare || type !== 'share'), + ); +}; + +// Add `type` to the visible list at `atIndex` (default: end). No-op if present. +export const addToolToToolbar = ( + visible: AnnotationToolType[], + type: AnnotationToolType, + atIndex?: number, +): AnnotationToolType[] => { + if (visible.includes(type)) return visible; + const next = [...visible]; + next.splice(atIndex ?? next.length, 0, type); + return next; +}; + +// Remove `type` from the visible list. No-op if absent. +export const removeToolFromToolbar = ( + visible: AnnotationToolType[], + type: AnnotationToolType, +): AnnotationToolType[] => visible.filter((type_) => type_ !== type); + +// Move `fromType` to where `toType` currently sits within the visible list. +export const reorderToolbar = ( + visible: AnnotationToolType[], + fromType: AnnotationToolType, + toType: AnnotationToolType, +): AnnotationToolType[] => { + const from = visible.indexOf(fromType); + const to = visible.indexOf(toType); + if (from < 0 || to < 0 || from === to) return visible; + const next = [...visible]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/__tests__/utils/annotationToolbar.test.ts` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/annotationToolbar.ts src/__tests__/utils/annotationToolbar.test.ts +git commit -m "feat(annotator): add pure toolbar order/visibility helpers (#4014)" +``` + +--- + +## Task 3: Add the `annotationToolbarItems` view setting + default + +**Files:** +- Modify: `src/types/book.ts` +- Modify: `src/services/constants.ts` +- Modify: `src/__tests__/services/constants.test.ts` + +- [ ] **Step 1: Write the failing assertion** + +In `src/__tests__/services/constants.test.ts`, inside the existing `describe('DEFAULT_ANNOTATOR_CONFIG', ...)` block (around line 704), add a test. Add the import alongside the existing `DEFAULT_ANNOTATOR_CONFIG` import at the top of the file: + +```typescript +import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar'; +``` + +Then add inside the describe block: + +```typescript + test('annotationToolbarItems defaults to the eight non-share tools', () => { + expect(DEFAULT_ANNOTATOR_CONFIG.annotationToolbarItems).toEqual( + DEFAULT_ANNOTATION_TOOLBAR_ITEMS, + ); + expect(DEFAULT_ANNOTATOR_CONFIG.annotationToolbarItems).not.toContain('share'); + }); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm test src/__tests__/services/constants.test.ts` +Expected: FAIL — `annotationToolbarItems` is `undefined` (and a tsgo error that it's not on the type). + +- [ ] **Step 3: Add the field to the type** + +In `src/types/book.ts`, add to the `AnnotatorConfig` interface (after `annotationQuickAction`): + +```typescript +export interface AnnotatorConfig { + enableAnnotationQuickActions: boolean; + annotationQuickAction: AnnotationToolType | null; + annotationToolbarItems: AnnotationToolType[]; + copyToNotebook: boolean; + noteExportConfig: NoteExportConfig; +} +``` + +Confirm `AnnotationToolType` is already imported in `book.ts` (it is — `annotationQuickAction` uses it). + +- [ ] **Step 4: Add the default value** + +In `src/services/constants.ts`, add the import near the other imports: + +```typescript +import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar'; +``` + +Then add the field to `DEFAULT_ANNOTATOR_CONFIG`: + +```typescript +export const DEFAULT_ANNOTATOR_CONFIG: AnnotatorConfig = { + enableAnnotationQuickActions: true, + annotationQuickAction: null, + annotationToolbarItems: DEFAULT_ANNOTATION_TOOLBAR_ITEMS, + copyToNotebook: false, + noteExportConfig: DEFAULT_NOTE_EXPORT_CONFIG, +}; +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `pnpm test src/__tests__/services/constants.test.ts` +Expected: PASS. + +- [ ] **Step 6: Type-check the whole project** + +Run: `pnpm exec tsgo --noEmit` +Expected: no errors. (Confirms `DEFAULT_ANNOTATOR_CONFIG` still satisfies `AnnotatorConfig` and no test fixtures broke — fixtures spread `...DEFAULT_ANNOTATOR_CONFIG`, so they inherit the new field.) + +- [ ] **Step 7: Commit** + +```bash +git add src/types/book.ts src/services/constants.ts src/__tests__/services/constants.test.ts +git commit -m "feat(annotator): add annotationToolbarItems view setting (#4014)" +``` + +--- + +## Task 4: Share-text ladder helper + tests + +**Files:** +- Create: `src/utils/share.ts` +- Create: `src/__tests__/utils/share.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `src/__tests__/utils/share.test.ts`: + +```typescript +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +const shareTextMock = vi.fn().mockResolvedValue(undefined); +const writeClipboardMock = vi.fn().mockResolvedValue(undefined); + +vi.mock('@choochmeque/tauri-plugin-sharekit-api', () => ({ + shareText: (...args: unknown[]) => shareTextMock(...args), +})); + +vi.mock('@/utils/clipboard', () => ({ + writeTextToClipboard: (...args: unknown[]) => writeClipboardMock(...args), +})); + +import { shareSelectedText } from '@/utils/share'; + +describe('shareSelectedText', () => { + beforeEach(() => { + shareTextMock.mockClear().mockResolvedValue(undefined); + writeClipboardMock.mockClear().mockResolvedValue(undefined); + // @ts-expect-error - reset between tests + delete globalThis.navigator.share; + }); + + afterEach(() => { + // @ts-expect-error - cleanup + delete globalThis.navigator.share; + }); + + test('no-op on empty text', async () => { + await shareSelectedText('', undefined, { isMobileApp: true }); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('uses native shareText on mobile', async () => { + await shareSelectedText('hello', { x: 1, y: 2 }, { isMobileApp: true }); + expect(shareTextMock).toHaveBeenCalledWith('hello', { position: { x: 1, y: 2 } }); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('uses native shareText on macOS desktop', async () => { + await shareSelectedText('hello', undefined, { isMacOSApp: true }); + expect(shareTextMock).toHaveBeenCalledTimes(1); + }); + + test('does NOT use native shareText on Windows/Linux; falls to navigator.share', async () => { + const navShare = vi.fn().mockResolvedValue(undefined); + // @ts-expect-error - test stub + globalThis.navigator.share = navShare; + await shareSelectedText('hello', undefined, { isWindowsApp: true, hasWindow: true }); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(navShare).toHaveBeenCalledWith({ text: 'hello' }); + }); + + test('falls back to navigator.share when not a native share platform', async () => { + const navShare = vi.fn().mockResolvedValue(undefined); + // @ts-expect-error - test stub + globalThis.navigator.share = navShare; + await shareSelectedText('hello', undefined, null); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(navShare).toHaveBeenCalledWith({ text: 'hello' }); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('swallows navigator.share rejection (user dismissed) without clipboard fallback', async () => { + const navShare = vi.fn().mockRejectedValue(new Error('AbortError')); + // @ts-expect-error - test stub + globalThis.navigator.share = navShare; + await expect(shareSelectedText('hello', undefined, null)).resolves.toBeUndefined(); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('falls back to clipboard when no share method exists', async () => { + await shareSelectedText('hello', undefined, null); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(writeClipboardMock).toHaveBeenCalledWith('hello'); + }); + + test('falls back to navigator.share when native shareText throws', async () => { + shareTextMock.mockRejectedValueOnce(new Error('plugin unavailable')); + const navShare = vi.fn().mockResolvedValue(undefined); + // @ts-expect-error - test stub + globalThis.navigator.share = navShare; + await shareSelectedText('hello', undefined, { isMobileApp: true }); + expect(navShare).toHaveBeenCalledWith({ text: 'hello' }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test src/__tests__/utils/share.test.ts` +Expected: FAIL — `Cannot find module '@/utils/share'`. + +- [ ] **Step 3: Implement the helper** + +Create `src/utils/share.ts`: + +```typescript +import { writeTextToClipboard } from '@/utils/clipboard'; + +export interface SharePosition { + x: number; + y: number; + preferredEdge?: 'top' | 'bottom' | 'left' | 'right'; +} + +/** Minimal slice of AppService needed to decide the native-share path. */ +interface ShareCapableService { + isMobileApp?: boolean; + isMacOSApp?: boolean; + isWindowsApp?: boolean; + isLinuxApp?: boolean; + hasWindow?: boolean; +} + +/** + * Open the OS share sheet for `text`, with graceful fallbacks. + * + * Ladder: + * 1. Native sharekit on mobile + macOS only. Windows/Linux are excluded: the + * plugin's share UI can freeze the app on Windows (issue #4343) and is not + * functional on Linux — `nativeAppService` gates `shareFile` the same way. + * 2. `navigator.share` (web / PWA). A rejection means the user dismissed the + * sheet — respect it, don't silently copy. + * 3. Clipboard, as a last resort when no share method exists. + */ +export const shareSelectedText = async ( + text: string, + position?: SharePosition, + appService?: ShareCapableService | null, +): Promise => { + if (!text) return; + + if (appService?.isMobileApp || appService?.isMacOSApp) { + try { + const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api'); + await shareText(text, { position }); + return; + } catch (err) { + console.error('shareText failed; falling back:', err); + } + } + + if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { + try { + await navigator.share({ text }); + } catch { + // User dismissed or share-time error; respect the choice. + } + return; + } + + await writeTextToClipboard(text); +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm test src/__tests__/utils/share.test.ts` +Expected: PASS (all cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/share.ts src/__tests__/utils/share.test.ts +git commit -m "feat(annotator): add shareSelectedText ladder helper (#4014)" +``` + +--- + +## Task 5: Wire Share + toolbar filtering into the live Annotator + +**Files:** +- Modify: `src/app/reader/components/annotator/Annotator.tsx` + +> No new unit test: `Annotator.tsx` is a large composed component with heavy +> runtime deps; its testable logic already lives in the Task 2/4 helpers. +> Verification here is tsgo + lint + the manual check in Task 8. + +- [ ] **Step 1: Add imports** + +Near the other `@/utils` imports in `Annotator.tsx` (e.g. by the `writeTextToClipboard` import on line ~50), add: + +```typescript +import { shareSelectedText } from '@/utils/share'; +import { getToolbarToolTypes } from '@/utils/annotationToolbar'; +import { AnnotationToolType } from '@/types/annotator'; +``` + +(If `AnnotationToolType` is already imported in the file, do not duplicate it — only add the two `@/utils` imports.) + +- [ ] **Step 2: Add `canShare` and `handleShare` near the other handlers** + +Add right after `handleCopy` (it ends around line 884, before the `copyToNotebook` early return logic finishes — place this as a new top-level const within the component, e.g. just after the full `handleCopy` definition): + +```typescript + const canShare = + !!appService?.isMobileApp || + !!appService?.isMacOSApp || + (typeof navigator !== 'undefined' && typeof navigator.share === 'function'); + + const handleShare = () => { + if (!selection?.text) return; + const position = trianglePosition + ? { + x: trianglePosition.point.x, + y: trianglePosition.point.y, + preferredEdge: 'bottom' as const, + } + : undefined; + void shareSelectedText(selection.text, position, appService); + handleDismissPopupAndSelection(); + }; +``` + +- [ ] **Step 3: Add the quick-action case** + +In `handleQuickAction`'s `switch (action)` block (around line 720), add a case (after the `tts` case): + +```typescript + case 'share': + handleShare(); + break; +``` + +- [ ] **Step 4: Replace the `toolButtons` builder with a filtered/ordered one** + +Replace the existing `const toolButtons = annotationToolButtons.map(({ type, label, Icon }) => { switch (type) { ... } });` block (around lines 1449-1491) with: + +```typescript + const buildToolButton = (type: AnnotationToolType) => { + const def = annotationToolButtons.find((button) => button.type === type); + if (!def) return null; + const { label, Icon } = def; + switch (type) { + case 'copy': + return { tooltipText: _(label), Icon, onClick: handleCopy }; + case 'highlight': + return { + tooltipText: selectionAnnotated ? _('Delete Highlight') : _(label), + Icon: selectionAnnotated ? RiDeleteBinLine : Icon, + onClick: handleHighlight, + }; + case 'annotate': + return { tooltipText: _(label), Icon, onClick: handleAnnotate }; + case 'search': + return { tooltipText: _(label), Icon, onClick: handleSearch }; + case 'dictionary': + return { tooltipText: _(label), Icon, onClick: handleDictionary }; + case 'translate': + return { tooltipText: _(label), Icon, onClick: handleTranslation }; + case 'tts': + return { tooltipText: _(label), Icon, onClick: handleSpeakText }; + case 'proofread': + return { + tooltipText: _(label), + Icon, + onClick: handleProofread, + disabled: bookData.book?.format !== 'EPUB', + }; + case 'share': + return { tooltipText: _(label), Icon, onClick: handleShare }; + default: + return null; + } + }; + + const toolButtons = getToolbarToolTypes(viewSettings.annotationToolbarItems, canShare) + .map(buildToolButton) + .filter((button): button is NonNullable => button !== null); +``` + +- [ ] **Step 5: Type-check and lint** + +Run: `pnpm lint` +Expected: no errors. (Confirms the `viewSettings.annotationToolbarItems` access, the new handlers, and the `toolButtons` shape all type-check against `AnnotationPopup`'s `buttons` prop.) + +- [ ] **Step 6: Commit** + +```bash +git add src/app/reader/components/annotator/Annotator.tsx +git commit -m "feat(annotator): render Share tool and honor toolbar order in selection popup (#4014)" +``` + +--- + +## Task 6: The drag-and-drop customizer sub-page + +**Files:** +- Create: `src/components/settings/AnnotationToolbarCustomizer.tsx` + +> Verification is tsgo + lint + the manual drag/tap check in Task 8. The state +> transitions reuse the Task 2 helpers (already unit-tested). + +- [ ] **Step 1: Create the component** + +Create `src/components/settings/AnnotationToolbarCustomizer.tsx`: + +```typescript +import clsx from 'clsx'; +import React, { useState } from 'react'; +import { + DndContext, + PointerSensor, + TouchSensor, + closestCenter, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useReaderStore } from '@/store/readerStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { saveViewSettings } from '@/helpers/settings'; +import { AnnotationToolType } from '@/types/annotator'; +import { annotationToolButtons } from '@/app/reader/components/annotator/AnnotationTools'; +import { + getAvailableToolTypes, + getToolbarToolTypes, + addToolToToolbar, + removeToolFromToolbar, + reorderToolbar, +} from '@/utils/annotationToolbar'; +import SubPageHeader from './SubPageHeader'; + +interface AnnotationToolbarCustomizerProps { + bookKey: string; + onBack: () => void; +} + +const toolButtonOf = (type: AnnotationToolType) => + annotationToolButtons.find((button) => button.type === type); + +interface ToolChipProps { + type: AnnotationToolType; + label: string; + onActivate: () => void; + _: (key: string) => string; +} + +const ToolChip: React.FC = ({ type, label, onActivate, _ }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: type, + }); + const Icon = toolButtonOf(type)?.Icon; + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + }; + return ( + + ); +}; + +const Zone: React.FC<{ + id: 'toolbar' | 'available'; + items: AnnotationToolType[]; + emptyHint: string; + renderChip: (type: AnnotationToolType) => React.ReactNode; +}> = ({ id, items, emptyHint, renderChip }) => { + const { setNodeRef } = useDroppable({ id }); + return ( + +
+ {items.length === 0 ? ( + {emptyHint} + ) : ( + items.map((type) => {renderChip(type)}) + )} +
+
+ ); +}; + +const AnnotationToolbarCustomizer: React.FC = ({ + bookKey, + onBack, +}) => { + const _ = useTranslation(); + const { envConfig, appService } = useEnv(); + const { getViewSettings } = useReaderStore(); + const { settings } = useSettingsStore(); + const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings; + + const canShare = + !!appService?.isMobileApp || + !!appService?.isMacOSApp || + (typeof navigator !== 'undefined' && typeof navigator.share === 'function'); + + const [toolbar, setToolbar] = useState(() => + getToolbarToolTypes(viewSettings.annotationToolbarItems, canShare), + ); + const [available, setAvailable] = useState(() => + getAvailableToolTypes(viewSettings.annotationToolbarItems, canShare), + ); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }), + ); + + const persist = (nextToolbar: AnnotationToolType[]) => { + saveViewSettings(envConfig, bookKey, 'annotationToolbarItems', nextToolbar, false, true); + }; + + const containerOf = (id: string): 'toolbar' | 'available' | null => { + if (id === 'toolbar' || toolbar.includes(id as AnnotationToolType)) return 'toolbar'; + if (id === 'available' || available.includes(id as AnnotationToolType)) return 'available'; + return null; + }; + + const moveToToolbar = (type: AnnotationToolType, atIndex?: number) => { + const next = addToolToToolbar(toolbar, type, atIndex); + setToolbar(next); + setAvailable(getAvailableToolTypes(next, canShare)); + persist(next); + }; + + const moveToAvailable = (type: AnnotationToolType) => { + const next = removeToolFromToolbar(toolbar, type); + setToolbar(next); + setAvailable(getAvailableToolTypes(next, canShare)); + persist(next); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return; + const activeId = active.id as AnnotationToolType; + const overId = over.id as string; + const from = containerOf(active.id as string); + const to = containerOf(overId); + if (!from || !to) return; + + if (from === 'toolbar' && to === 'toolbar') { + if (overId === 'toolbar' || overId === activeId) return; + const next = reorderToolbar(toolbar, activeId, overId as AnnotationToolType); + if (next !== toolbar) { + setToolbar(next); + persist(next); + } + return; + } + if (from === 'available' && to === 'toolbar') { + const insertAt = + overId === 'toolbar' ? toolbar.length : Math.max(0, toolbar.indexOf(overId as AnnotationToolType)); + moveToToolbar(activeId, insertAt); + return; + } + if (from === 'toolbar' && to === 'available') { + moveToAvailable(activeId); + return; + } + // from === 'available' && to === 'available': display-only, ignore. + }; + + const renderToolbarChip = (type: AnnotationToolType) => ( + moveToAvailable(type)} + _={_} + /> + ); + const renderAvailableChip = (type: AnnotationToolType) => ( + moveToToolbar(type)} + _={_} + /> + ); + + return ( +
+ + +
+
+
{_('In toolbar')}
+ +
+
+
{_('Available')}
+ +
+
+
+
+ ); +}; + +export default AnnotationToolbarCustomizer; +``` + +- [ ] **Step 2: Type-check and lint** + +Run: `pnpm lint` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/settings/AnnotationToolbarCustomizer.tsx +git commit -m "feat(settings): add drag-and-drop annotation toolbar customizer (#4014)" +``` + +--- + +## Task 7: Hook the customizer into the Behavior panel + +**Files:** +- Modify: `src/components/settings/ControlPanel.tsx` + +- [ ] **Step 1: Add imports** + +In `ControlPanel.tsx`, add `NavigationRow` to the existing primitives import and import the new component + the default constant: + +```typescript +import { BoxedList, NavigationRow, SettingsRow, SettingsSelect, SettingsSwitchRow } from './primitives'; +import AnnotationToolbarCustomizer from './AnnotationToolbarCustomizer'; +import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar'; +``` + +(The current import is `import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from './primitives';` — just add `NavigationRow` to it.) + +- [ ] **Step 2: Add sub-page state** + +Add alongside the other `useState` hooks in the component body (e.g. after `annotationQuickAction`): + +```typescript + const [showToolbarCustomizer, setShowToolbarCustomizer] = useState(false); +``` + +- [ ] **Step 3: Reset the toolbar order on panel reset** + +In `handleReset`, after the `resetToDefaults({...})` call and before `pageTurnerResetRef.current();`, add: + +```typescript + saveViewSettings( + envConfig, + bookKey, + 'annotationToolbarItems', + DEFAULT_ANNOTATION_TOOLBAR_ITEMS, + false, + true, + ); +``` + +- [ ] **Step 4: Early-return the sub-page** + +Immediately before the main `return (` of the component (after all hooks/handlers), add: + +```typescript + if (showToolbarCustomizer) { + return ( + setShowToolbarCustomizer(false)} + /> + ); + } +``` + +- [ ] **Step 5: Add the NavigationRow into the "Annotation Tools" BoxedList** + +Inside the existing `` block, after the `Copy to Notebook` `SettingsSwitchRow`, add: + +```typescript + setShowToolbarCustomizer(true)} + data-setting-id='settings.control.customizeToolbar' + /> +``` + +- [ ] **Step 6: Type-check and lint** + +Run: `pnpm lint` +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/settings/ControlPanel.tsx +git commit -m "feat(settings): open the toolbar customizer from the Behavior panel (#4014)" +``` + +--- + +## Task 8: Full verification + manual sanity + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full unit suite** + +Run: `pnpm test` +Expected: PASS (no regressions; the new `annotationToolbar`, `share`, and `constants` tests pass). + +- [ ] **Step 2: Lint + type-check** + +Run: `pnpm lint` +Expected: PASS (Biome + tsgo, web only). No Rust/Lua files changed, so those lanes are not triggered. + +- [ ] **Step 3: Manual sanity in dev-web** + +Run: `pnpm dev-web`, open a book, then: +- Select text → confirm the toolbar shows the configured tools in order; **Share is absent by default**. +- Settings → Behavior → Annotation Tools → **Customize Toolbar**: drag `Share` from "Available" into "In toolbar"; reorder a couple of tools; drag one back out. Confirm taps also move tools between rows. +- Re-select text in the reader → confirm the toolbar reflects the new set/order, and **Share opens the OS share sheet** (or, on web without `navigator.share`, copies as a last resort). +- Toggle E-ink (Settings → Behavior → Device, or Misc) and reopen the customizer → confirm chips have visible 1px borders and remain legible. +- Behavior panel **Reset** → confirm the toolbar returns to the default eight tools (Share hidden). + +- [ ] **Step 4: i18n extraction + translation (new strings)** + +Run: `pnpm i18n:extract` to pick up the new `_()` strings (`Share`, `Share text after selection`, `Customize Toolbar`, `In toolbar`, `Available`, `No tools — drag one here.`, `All tools are in the toolbar.`, `Drag to reorder, tap to move`, and the SubPageHeader description). This adds the keys with `__STRING_NOT_TRANSLATED__` placeholders across `public/locales/*/translation.json`. Then run the **`/i18n` skill** to fill the placeholders (it translates all locales). New strings are non-plural/non-proper-noun, so `en/translation.json` needs no manual entry (defaultValue = key). + +- [ ] **Step 5: Final commit (if extraction changed files)** + +```bash +git add public/locales +git commit -m "chore(i18n): extract annotation share/toolbar strings (#4014)" +``` + +--- + +## Notes for the implementer + +- **Don't restructure `Annotator.tsx`.** Only add the imports, `canShare`, `handleShare`, the quick-action case, and swap the `toolButtons` builder. Everything else stays. +- **`saveViewSettings` last two args** are `(skipGlobal, applyStyles)`. Use `(…, false, true)` to write through to the global default and apply — matching the existing `annotationQuickAction` save. +- **Per-book vs global:** the customizer writes via `saveViewSettings(bookKey, …, skipGlobal=false)`, so it updates the global default and the current book, consistent with the other Behavior settings. +- **Reduced-motion / a11y:** every chip is both tappable (zone toggle) and draggable (reorder/move); keyboard users and e-ink rely on the tap path. diff --git a/apps/readest-app/docs/superpowers/specs/2026-06-13-annotation-share-toolbar-design.md b/apps/readest-app/docs/superpowers/specs/2026-06-13-annotation-share-toolbar-design.md new file mode 100644 index 00000000..f5634f9b --- /dev/null +++ b/apps/readest-app/docs/superpowers/specs/2026-06-13-annotation-share-toolbar-design.md @@ -0,0 +1,177 @@ +# Share intent + customizable annotation toolbar (#4014) + +**Issue:** [readest/readest#4014](https://github.com/readest/readest/issues/4014) — _FR: add share intent in mobile_ + +## Problem + +When a user selects text in a book, the selection toolbar offers Copy, Highlight, +Annotate, Search, Dictionary, Translate, Speak, Proofread — but no way to send the +selection to another app (a dictionary, AI chat, Anki, a browser, a messenger). +"Copy" is a cumbersome stopgap that needs several extra taps to reach the target app. + +A native **Share** action solves this. Adding it also surfaces a second concern: the +toolbar already shows 8 tools and renders them all unconditionally (scrollable on +narrow screens). A 9th tool worsens the crowding, and different users want different +tools up front (language learners want Dictionary + Share; others want Highlight + +Annotate). So alongside Share we let users **choose which tools appear and in what +order**. + +## Goals + +1. Add a **Share** tool to the selection toolbar that opens the native share sheet + for the selected text, with a graceful web fallback. +2. Let users **customize the toolbar** — show/hide tools and reorder them — via a + drag-and-drop sub-page in Settings → Behavior. + +## Non-goals + +- No new "share targets" management (we hand off to the OS share sheet; the OS owns + the target list). +- No change to the existing Quick Action mechanism beyond making Share selectable as + one. +- No per-platform toolbar presets; one customizable list, gated only by capability. + +## Current architecture (as-is) + +- **Tool definitions** — `src/app/reader/components/annotator/AnnotationTools.tsx` + exports `annotationToolButtons` (ordered array of `{ type, label, tooltip, Icon, + quickAction? }`) and `annotationToolQuickActions` (the `quickAction: true` subset). +- **Tool types** — `src/types/annotator.ts` `AnnotationToolType` union. +- **Toolbar rendering** — `Annotator.tsx` maps `annotationToolButtons` → `toolButtons` + (a `switch` on `type` that binds each button's handler), passed to + `AnnotationPopup`. Every button is rendered; there is no visibility filter today. +- **Quick action** — `handleQuickAction()` in `Annotator.tsx` runs a single tool + immediately on selection when `enableAnnotationQuickActions` + + `annotationQuickAction` are set. +- **Settings (Behavior panel)** — `src/components/settings/ControlPanel.tsx` + (tab id `Control`, label "Behavior") already has an "Annotation Tools" `BoxedList` + with Enable Quick Actions / Quick Action select / Copy to Notebook. +- **View-setting storage** — `AnnotatorConfig` in `src/types/book.ts`; defaults in + `DEFAULT_ANNOTATOR_CONFIG` (`src/services/constants.ts`); persisted via + `saveViewSettings` (global default, per-book overridable). Missing fields merge to + the default. +- **Share plumbing (already present)** — `@choochmeque/tauri-plugin-sharekit-api` + exposes `shareText(text, { position?, mimeType? })`. `ShareBookDialog.tsx` / + `SharedLinksSection.tsx` already use it with the ladder: sharekit → `navigator.share` + → clipboard. `appService` exposes `shareFile` but **not** a text-share method. +- **Drag-and-drop** — `@dnd-kit/core` + `/sortable` + `/utilities` are dependencies; + `src/components/settings/CustomDictionaries.tsx` already implements drag-to-reorder + with `DndContext` / `SortableContext` / `useSortable`. +- **Settings sub-pages** — a panel holds a `showX` boolean, early-returns + ``, and exposes a `NavigationRow` to enter it (see + `LangPanel.tsx` → `CustomDictionaries`, gated by `showCustomDictionaries`). + +## Design + +### 1. Share tool + +- Add `'share'` to `AnnotationToolType` (`src/types/annotator.ts`). +- Add a `share` entry to `annotationToolButtons` (`AnnotationTools.tsx`): + `label: _('Share')`, `tooltip: _('Share text after selection')`, + `Icon: PiShareFat` (react-icons/pi), `quickAction: true`. +- **Share helper** — new helper `shareSelectedText(text, position?, appService?)` in + `src/utils/share.ts`, that runs the ladder used by `ShareBookDialog`: + 1. If `appService?.isMobileApp || appService?.hasWindow`: dynamic-import + `shareText` and call `shareText(text, { position })`. On success, return. + 2. Else/on throw: if `navigator.share` exists, `await navigator.share({ text })` + (swallow `AbortError` — user dismissed). Return. + 3. Last resort: `writeTextToClipboard(text)`. + Extracting this keeps the ladder unit-testable with the existing sharekit mock and + keeps `Annotator.tsx` thin. +- **`handleShare()` in `Annotator.tsx`** — guards on `selection?.text`, computes an + anchor `position` from `trianglePosition` (for the iPad/macOS popover; ignored on + phones), calls `shareSelectedText(...)`, then `handleDismissPopupAndSelection()`. + Wire it into the `toolButtons` `switch` (`case 'share'`) and the + `handleQuickAction` `switch` (`case 'share'`). +- **Capability gating (`canShare`)** — a boolean derived from + `appService?.isMobileApp || appService?.isMacOSApp || + (typeof navigator !== 'undefined' && !!navigator.share)`. Windows/Linux desktop are + deliberately excluded: sharekit's native share is only functional on macOS + mobile, + and on Windows its share UI can freeze the app (issue #4343 — `nativeAppService` + already gates `shareFile` to macOS-only on desktop for the same reason). When + `false`, Share is omitted from both the live toolbar and the customizer (so we never + show a "Share" that silently just copies). `shareSelectedText` mirrors this: native + branch fires only on `isMobileApp || isMacOSApp`. + +### 2. Data model + +- New field on `AnnotatorConfig` (`src/types/book.ts`): + `annotationToolbarItems: AnnotationToolType[]` — the **ordered, visible** tools. + "Available" (hidden) tools = all tool types minus this list, displayed in the + canonical `annotationToolButtons` order. +- Default (`DEFAULT_ANNOTATOR_CONFIG`): the existing 8 tools in their current order, + **without** `share`: + `['copy', 'highlight', 'annotate', 'search', 'dictionary', 'translate', 'tts', + 'proofread']`. + → Existing users keep their exact current toolbar after upgrade (missing-field + merge → this default); Share starts hidden in the Available tray. +- **Forward-compat note:** a tool type added in a future release won't be in an + existing user's saved array, so it defaults to "Available" (hidden) for them and + visible only for fresh defaults. Acceptable; documented here so it's a deliberate + choice, not a surprise. + +### 3. Toolbar rendering + +- A pure helper `getToolbarToolTypes(items, canShare)` returns the ordered list of + tool types to render: it takes `annotationToolbarItems`, drops `'share'` when + `!canShare`, and (defensively) drops any unknown types. `Annotator.tsx` builds + `toolButtons` from this list instead of the full `annotationToolButtons`, looking + up each type's handler. Unit-tested. + +### 4. Settings customizer (sub-page) + +- New component + `src/components/settings/AnnotationToolbarCustomizer.tsx` with an `onBack` prop and + a `SubPageHeader` (mirrors `CustomDictionaries`). +- `ControlPanel.tsx`: add `showToolbarCustomizer` state, early-return + ` setShowToolbarCustomizer(false)} />`, + and a `NavigationRow` (title `_('Customize Toolbar')`) inside the existing + "Annotation Tools" `BoxedList`. +- **Two zones**, both rendering the real tool icon-buttons: + - **"In toolbar"** — the ordered visible tools; drag to reorder. + - **"Available"** — the hidden tools; drag one into "In toolbar" to add, drag a + visible tool back to remove. + Built with `@dnd-kit` using the multiple-containers (two droppable lists) pattern, + reusing the sensor/handle conventions from `CustomDictionaries`. +- **Touch / e-ink / a11y affordance:** in addition to cross-zone drag, tapping a tool + toggles it between zones (drag can be fiddly on e-ink and is invisible to keyboard + users). Tapping an Available tool **appends** it to the end of "In toolbar"; tapping + a visible tool moves it to "Available". Buttons use `eink-bordered`; hierarchy never + relies on color/shadow alone. +- On every change, persist the new order via + `saveViewSettings(envConfig, bookKey, 'annotationToolbarItems', next, false, true)`. +- Reset: include `annotationToolbarItems` in `ControlPanel`'s `handleReset` wiring so + "Reset" restores the default order. + +### 5. i18n + +New `stubTranslation` strings (extracted later via the i18n workflow): `Share`, +`Share text after selection`, `Customize Toolbar`, and the customizer's zone labels +(`In toolbar`, `Available`) and `Drag to reorder` / drag hints. `en/translation.json` +needs no manual entry (no plurals/proper nouns). + +## Testing (test-first) + +1. `getToolbarToolTypes` — order preserved; `share` dropped when `!canShare`; unknown + types dropped. (pure unit test) +2. The customizer's add/remove/reorder reducer — moving between zones and reordering + produce the expected `annotationToolbarItems`. (pure unit test on the extracted + reducer) +3. `shareSelectedText` ladder — with `@choochmeque/tauri-plugin-sharekit-api` mocked + (mirrors `src/__tests__/services/native-app-service-share.test.ts`): calls + `shareText` on mobile/window; falls back to `navigator.share`; falls back to + clipboard when neither is available; swallows `AbortError`. +4. `DEFAULT_ANNOTATOR_CONFIG.annotationToolbarItems` shape/order assertion in + `src/__tests__/services/constants.test.ts`. + +## Verification (done-conditions) + +- `pnpm test` (unit), `pnpm lint` (Biome + tsgo). No Rust/Lua files change, so those + lanes are not triggered. +- Manual sanity in dev: select text → Share opens the sheet; customizer reorders / + shows / hides and the live toolbar reflects it; e-ink toggle still legible. + +## Out-of-scope / deferred + +- Native Android `mimeType` refinements for Share (use the plugin default). +- Surfacing Share on Windows/Linux desktop (excluded by `canShare`; see gating above). diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 31318d8c..d67f0597 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1844,5 +1844,15 @@ "Reference Pages": "صفحات مرجعية", "Reference Page Count": "عدد الصفحات المرجعية", "Lookup app reset. The next lookup will ask again.": "تمت إعادة تعيين تطبيق البحث. سيُطلب منك الاختيار في المرة التالية.", - "System Lookup App": "تطبيق البحث في النظام" + "System Lookup App": "تطبيق البحث في النظام", + "Share": "مشاركة", + "Share text after selection": "مشاركة النص بعد التحديد", + "Customize Toolbar": "تخصيص شريط الأدوات", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "اسحب الأدوات بين الصفوف لإظهارها أو إخفائها وإعادة ترتيب شريط الأدوات. يمكنك أيضًا النقر على أداة لنقلها.", + "In toolbar": "في شريط الأدوات", + "Available": "متاحة", + "All tools are in the toolbar.": "جميع الأدوات موجودة في شريط الأدوات.", + "Add all": "إضافة الكل", + "Clear all": "مسح الكل", + "No tools, drag one here": "لا توجد أدوات، اسحب واحدة إلى هنا" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 7b690633..a3acbc34 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "রেফারেন্স পৃষ্ঠা", "Reference Page Count": "রেফারেন্স পৃষ্ঠার সংখ্যা", "Lookup app reset. The next lookup will ask again.": "লুকআপ অ্যাপ রিসেট করা হয়েছে। পরবর্তী লুকআপে আবার জিজ্ঞাসা করা হবে।", - "System Lookup App": "সিস্টেম লুকআপ অ্যাপ" + "System Lookup App": "সিস্টেম লুকআপ অ্যাপ", + "Share": "শেয়ার করুন", + "Share text after selection": "নির্বাচনের পর টেক্সট শেয়ার করুন", + "Customize Toolbar": "টুলবার কাস্টমাইজ করুন", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "সারিগুলোর মধ্যে টুল টেনে এনে সেগুলো দেখান বা লুকান এবং টুলবার পুনর্বিন্যাস করুন। আপনি কোনো টুল সরাতে সেটিতে ট্যাপও করতে পারেন।", + "In toolbar": "টুলবারে", + "Available": "উপলব্ধ", + "All tools are in the toolbar.": "সব টুল টুলবারে রয়েছে।", + "Add all": "সব যোগ করুন", + "Clear all": "সব মুছুন", + "No tools, drag one here": "কোনো টুল নেই, এখানে একটি টেনে আনুন" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 8c620e32..74e33225 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "དཔེ་སྟོན་ཤོག་ངོས།", "Reference Page Count": "དཔེ་སྟོན་ཤོག་ངོས་ཀྱི་གྲངས།", "Lookup app reset. The next lookup will ask again.": "འཚོལ་བཤེར་ཉེར་སྤྱོད་བསྐྱར་སྒྲིག་བྱས་ཟིན། རྗེས་མའི་འཚོལ་བཤེར་སྐབས་སླར་ཡང་འདྲི་ངེས།", - "System Lookup App": "རྒྱུད་ཁོངས་འཚོལ་བཤེར་ཉེར་སྤྱོད།" + "System Lookup App": "རྒྱུད་ཁོངས་འཚོལ་བཤེར་ཉེར་སྤྱོད།", + "Share": "མཉམ་སྤྱོད།", + "Share text after selection": "འདེམ་པའི་རྗེས་སུ་ཡི་གེ་མཉམ་སྤྱོད་བྱེད།", + "Customize Toolbar": "ལག་ཆ་ཕྲ་མོའི་ཕྲེང་རང་སྒྲིག", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "ལག་ཆ་རྣམས་ཕྲེང་བར་འདྲུད་ནས་མངོན་པའམ་སྦས་ཐུབ་ལ་ལག་ཆའི་ཕྲེང་གི་གོ་རིམ་ཡང་བསྒྱུར་ཐུབ། ལག་ཆ་ཞིག་ལ་མནན་ནས་སྤོ་ཐུབ།", + "In toolbar": "ལག་ཆའི་ཕྲེང་ནང་།", + "Available": "ཐོབ་རུང་།", + "All tools are in the toolbar.": "ལག་ཆ་ཡོངས་རྫོགས་ལག་ཆའི་ཕྲེང་ནང་ཡོད།", + "Add all": "ཚང་མ་སྣོན་པ།", + "Clear all": "ཚང་མ་གཙང་སེལ།", + "No tools, drag one here": "ལག་ཆ་མེད། འདིར་གཅིག་འདྲུད་རོགས།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index ba2ff30a..30c1b33f 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "Referenzseiten", "Reference Page Count": "Anzahl der Referenzseiten", "Lookup app reset. The next lookup will ask again.": "Nachschlage-App zurückgesetzt. Bei der nächsten Suche wird erneut gefragt.", - "System Lookup App": "System-Nachschlage-App" + "System Lookup App": "System-Nachschlage-App", + "Share": "Teilen", + "Share text after selection": "Text nach Auswahl teilen", + "Customize Toolbar": "Werkzeugleiste anpassen", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Ziehe Werkzeuge zwischen den Zeilen, um sie ein- oder auszublenden und die Werkzeugleiste neu anzuordnen. Du kannst auch auf ein Werkzeug tippen, um es zu verschieben.", + "In toolbar": "In der Werkzeugleiste", + "Available": "Verfügbar", + "All tools are in the toolbar.": "Alle Werkzeuge befinden sich in der Werkzeugleiste.", + "Add all": "Alle hinzufügen", + "Clear all": "Alle entfernen", + "No tools, drag one here": "Keine Werkzeuge, zieh eines hierher" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 94cf3ddc..a031aa69 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "Σελίδες αναφοράς", "Reference Page Count": "Αριθμός σελίδων αναφοράς", "Lookup app reset. The next lookup will ask again.": "Η εφαρμογή αναζήτησης επαναφέρθηκε. Στην επόμενη αναζήτηση θα ερωτηθείτε ξανά.", - "System Lookup App": "Εφαρμογή αναζήτησης συστήματος" + "System Lookup App": "Εφαρμογή αναζήτησης συστήματος", + "Share": "Κοινοποίηση", + "Share text after selection": "Κοινοποίηση κειμένου μετά την επιλογή", + "Customize Toolbar": "Προσαρμογή γραμμής εργαλείων", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Σύρετε εργαλεία ανάμεσα στις σειρές για να τα εμφανίσετε ή να τα αποκρύψετε και να αναδιατάξετε τη γραμμή εργαλείων. Μπορείτε επίσης να πατήσετε ένα εργαλείο για να το μετακινήσετε.", + "In toolbar": "Στη γραμμή εργαλείων", + "Available": "Διαθέσιμα", + "All tools are in the toolbar.": "Όλα τα εργαλεία βρίσκονται στη γραμμή εργαλείων.", + "Add all": "Προσθήκη όλων", + "Clear all": "Εκκαθάριση όλων", + "No tools, drag one here": "Δεν υπάρχουν εργαλεία, σύρετε ένα εδώ" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index f1f08b8d..7af11e25 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1745,5 +1745,15 @@ "Reference Pages": "Páginas de referencia", "Reference Page Count": "Número de páginas de referencia", "Lookup app reset. The next lookup will ask again.": "Se restableció la app de búsqueda. La próxima vez se preguntará de nuevo.", - "System Lookup App": "App de búsqueda del sistema" + "System Lookup App": "App de búsqueda del sistema", + "Share": "Compartir", + "Share text after selection": "Compartir texto tras la selección", + "Customize Toolbar": "Personalizar barra de herramientas", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Arrastra las herramientas entre las filas para mostrarlas u ocultarlas y reordenar la barra de herramientas. También puedes tocar una herramienta para moverla.", + "In toolbar": "En la barra de herramientas", + "Available": "Disponibles", + "All tools are in the toolbar.": "Todas las herramientas están en la barra de herramientas.", + "Add all": "Añadir todo", + "Clear all": "Borrar todo", + "No tools, drag one here": "No hay herramientas, arrastra una aquí" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index eb68943a..e3a760e8 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "صفحات مرجع", "Reference Page Count": "تعداد صفحات مرجع", "Lookup app reset. The next lookup will ask again.": "برنامهٔ جستجو بازنشانی شد. در جستجوی بعدی دوباره پرسیده می‌شود.", - "System Lookup App": "برنامهٔ جستجوی سیستم" + "System Lookup App": "برنامهٔ جستجوی سیستم", + "Share": "اشتراک‌گذاری", + "Share text after selection": "اشتراک‌گذاری متن پس از انتخاب", + "Customize Toolbar": "سفارشی‌سازی نوار ابزار", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "ابزارها را بین ردیف‌ها بکشید تا آن‌ها را نمایش داده یا پنهان کنید و ترتیب نوار ابزار را تغییر دهید. همچنین می‌توانید برای جابه‌جایی یک ابزار روی آن ضربه بزنید.", + "In toolbar": "در نوار ابزار", + "Available": "در دسترس", + "All tools are in the toolbar.": "همهٔ ابزارها در نوار ابزار هستند.", + "Add all": "افزودن همه", + "Clear all": "پاک کردن همه", + "No tools, drag one here": "هیچ ابزاری نیست، یکی را به اینجا بکشید" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index f0340254..fd668f81 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1745,5 +1745,15 @@ "Reference Pages": "Pages de référence", "Reference Page Count": "Nombre de pages de référence", "Lookup app reset. The next lookup will ask again.": "Application de recherche réinitialisée. La prochaine recherche redemandera.", - "System Lookup App": "Application de recherche système" + "System Lookup App": "Application de recherche système", + "Share": "Partager", + "Share text after selection": "Partager le texte après la sélection", + "Customize Toolbar": "Personnaliser la barre d’outils", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Faites glisser les outils entre les lignes pour les afficher ou les masquer et réorganiser la barre d’outils. Vous pouvez aussi toucher un outil pour le déplacer.", + "In toolbar": "Dans la barre d’outils", + "Available": "Disponibles", + "All tools are in the toolbar.": "Tous les outils sont dans la barre d’outils.", + "Add all": "Tout ajouter", + "Clear all": "Tout effacer", + "No tools, drag one here": "Aucun outil, glissez-en un ici" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index b7d4b8a1..411778be 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1745,5 +1745,15 @@ "Reference Pages": "עמודי ייחוס", "Reference Page Count": "מספר עמודי ייחוס", "Lookup app reset. The next lookup will ask again.": "אפליקציית החיפוש אופסה. בחיפוש הבא תוצג שאלה שוב.", - "System Lookup App": "אפליקציית חיפוש מערכת" + "System Lookup App": "אפליקציית חיפוש מערכת", + "Share": "שיתוף", + "Share text after selection": "שיתוף טקסט לאחר הבחירה", + "Customize Toolbar": "התאמת סרגל הכלים", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "גררו כלים בין השורות כדי להציג או להסתיר אותם ולסדר מחדש את סרגל הכלים. אפשר גם להקיש על כלי כדי להעביר אותו.", + "In toolbar": "בסרגל הכלים", + "Available": "זמינים", + "All tools are in the toolbar.": "כל הכלים נמצאים בסרגל הכלים.", + "Add all": "הוסף הכול", + "Clear all": "נקה הכול", + "No tools, drag one here": "אין כלים, גררו אחד לכאן" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 40a37f07..8c9dedcd 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "संदर्भ पृष्ठ", "Reference Page Count": "संदर्भ पृष्ठ संख्या", "Lookup app reset. The next lookup will ask again.": "लुकअप ऐप रीसेट कर दिया गया। अगली बार फिर से पूछा जाएगा।", - "System Lookup App": "सिस्टम लुकअप ऐप" + "System Lookup App": "सिस्टम लुकअप ऐप", + "Share": "साझा करें", + "Share text after selection": "चयन के बाद टेक्स्ट साझा करें", + "Customize Toolbar": "टूलबार अनुकूलित करें", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "टूल को पंक्तियों के बीच खींचकर उन्हें दिखाएँ या छिपाएँ और टूलबार का क्रम बदलें। आप किसी टूल को स्थानांतरित करने के लिए उस पर टैप भी कर सकते हैं।", + "In toolbar": "टूलबार में", + "Available": "उपलब्ध", + "All tools are in the toolbar.": "सभी टूल टूलबार में हैं।", + "Add all": "सभी जोड़ें", + "Clear all": "सभी साफ़ करें", + "No tools, drag one here": "कोई टूल नहीं, यहाँ एक खींचें" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 1368de4a..1f239ac7 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "Referenciaoldalak", "Reference Page Count": "Referenciaoldalak száma", "Lookup app reset. The next lookup will ask again.": "A keresőalkalmazás visszaállítva. A következő keresésnél újra rákérdez.", - "System Lookup App": "Rendszer keresőalkalmazás" + "System Lookup App": "Rendszer keresőalkalmazás", + "Share": "Megosztás", + "Share text after selection": "Szöveg megosztása kijelölés után", + "Customize Toolbar": "Eszköztár testreszabása", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Húzd az eszközöket a sorok között a megjelenítésükhöz vagy elrejtésükhöz, és az eszköztár átrendezéséhez. Egy eszközre koppintva is áthelyezheted azt.", + "In toolbar": "Az eszköztáron", + "Available": "Elérhető", + "All tools are in the toolbar.": "Minden eszköz az eszköztáron van.", + "Add all": "Összes hozzáadása", + "Clear all": "Összes törlése", + "No tools, drag one here": "Nincsenek eszközök, húzz ide egyet" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index a96f3ac5..25674406 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "Halaman referensi", "Reference Page Count": "Jumlah halaman referensi", "Lookup app reset. The next lookup will ask again.": "Aplikasi pencarian disetel ulang. Pencarian berikutnya akan menanyakan lagi.", - "System Lookup App": "Aplikasi pencarian sistem" + "System Lookup App": "Aplikasi pencarian sistem", + "Share": "Bagikan", + "Share text after selection": "Bagikan teks setelah pemilihan", + "Customize Toolbar": "Sesuaikan Bilah Alat", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Seret alat di antara baris untuk menampilkan atau menyembunyikannya dan mengatur ulang bilah alat. Anda juga dapat mengetuk alat untuk memindahkannya.", + "In toolbar": "Di bilah alat", + "Available": "Tersedia", + "All tools are in the toolbar.": "Semua alat ada di bilah alat.", + "Add all": "Tambah semua", + "Clear all": "Hapus semua", + "No tools, drag one here": "Tidak ada alat, seret satu ke sini" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index be67e9e1..ca28ae57 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1745,5 +1745,15 @@ "Reference Pages": "Pagine di riferimento", "Reference Page Count": "Numero di pagine di riferimento", "Lookup app reset. The next lookup will ask again.": "App di ricerca reimpostata. Alla prossima ricerca verrà richiesto di nuovo.", - "System Lookup App": "App di ricerca di sistema" + "System Lookup App": "App di ricerca di sistema", + "Share": "Condividi", + "Share text after selection": "Condividi il testo dopo la selezione", + "Customize Toolbar": "Personalizza barra degli strumenti", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Trascina gli strumenti tra le righe per mostrarli o nasconderli e riordinare la barra degli strumenti. Puoi anche toccare uno strumento per spostarlo.", + "In toolbar": "Nella barra degli strumenti", + "Available": "Disponibili", + "All tools are in the toolbar.": "Tutti gli strumenti sono nella barra degli strumenti.", + "Add all": "Aggiungi tutto", + "Clear all": "Cancella tutto", + "No tools, drag one here": "Nessuno strumento, trascinane uno qui" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 5a22bebd..d3f39ed7 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "参照ページ", "Reference Page Count": "参照ページ数", "Lookup app reset. The next lookup will ask again.": "検索アプリをリセットしました。次回の検索時に再度確認します。", - "System Lookup App": "システム検索アプリ" + "System Lookup App": "システム検索アプリ", + "Share": "共有", + "Share text after selection": "選択後にテキストを共有", + "Customize Toolbar": "ツールバーをカスタマイズ", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "ツールを行間でドラッグして表示/非表示を切り替え、ツールバーを並べ替えます。ツールをタップして移動することもできます。", + "In toolbar": "ツールバー内", + "Available": "利用可能", + "All tools are in the toolbar.": "すべてのツールがツールバーにあります。", + "Add all": "すべて追加", + "Clear all": "すべてクリア", + "No tools, drag one here": "ツールがありません、ここにドラッグしてください" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 122a4699..9ea0e27a 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "참조 페이지", "Reference Page Count": "참조 페이지 수", "Lookup app reset. The next lookup will ask again.": "검색 앱을 초기화했습니다. 다음 검색 시 다시 묻습니다.", - "System Lookup App": "시스템 검색 앱" + "System Lookup App": "시스템 검색 앱", + "Share": "공유", + "Share text after selection": "선택 후 텍스트 공유", + "Customize Toolbar": "도구 모음 사용자 지정", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "도구를 행 사이로 드래그하여 표시하거나 숨기고 도구 모음의 순서를 바꿉니다. 도구를 탭하여 옮길 수도 있습니다.", + "In toolbar": "도구 모음에 있음", + "Available": "사용 가능", + "All tools are in the toolbar.": "모든 도구가 도구 모음에 있습니다.", + "Add all": "모두 추가", + "Clear all": "모두 지우기", + "No tools, drag one here": "도구가 없습니다, 여기로 하나를 드래그하세요" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 92eae03e..57c580e0 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "Halaman rujukan", "Reference Page Count": "Bilangan halaman rujukan", "Lookup app reset. The next lookup will ask again.": "Apl carian ditetapkan semula. Carian seterusnya akan bertanya lagi.", - "System Lookup App": "Apl carian sistem" + "System Lookup App": "Apl carian sistem", + "Share": "Kongsi", + "Share text after selection": "Kongsi teks selepas pemilihan", + "Customize Toolbar": "Sesuaikan Bar Alat", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Seret alat antara baris untuk menunjukkan atau menyembunyikannya dan menyusun semula bar alat. Anda juga boleh mengetik alat untuk mengalihkannya.", + "In toolbar": "Dalam bar alat", + "Available": "Tersedia", + "All tools are in the toolbar.": "Semua alat berada dalam bar alat.", + "Add all": "Tambah semua", + "Clear all": "Kosongkan semua", + "No tools, drag one here": "Tiada alat, seret satu ke sini" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 53680fee..fc986ae2 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "Referentiepagina's", "Reference Page Count": "Aantal referentiepagina's", "Lookup app reset. The next lookup will ask again.": "Opzoekapp gereset. De volgende keer wordt het opnieuw gevraagd.", - "System Lookup App": "Systeem-opzoekapp" + "System Lookup App": "Systeem-opzoekapp", + "Share": "Delen", + "Share text after selection": "Tekst delen na selectie", + "Customize Toolbar": "Werkbalk aanpassen", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Sleep gereedschappen tussen de rijen om ze te tonen of te verbergen en de werkbalk te herschikken. Je kunt ook op een gereedschap tikken om het te verplaatsen.", + "In toolbar": "Op de werkbalk", + "Available": "Beschikbaar", + "All tools are in the toolbar.": "Alle gereedschappen staan op de werkbalk.", + "Add all": "Alles toevoegen", + "Clear all": "Alles wissen", + "No tools, drag one here": "Geen gereedschappen, sleep er een hierheen" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 35d72eec..0db5aa94 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1778,5 +1778,15 @@ "Reference Pages": "Strony referencyjne", "Reference Page Count": "Liczba stron referencyjnych", "Lookup app reset. The next lookup will ask again.": "Zresetowano aplikację wyszukiwania. Przy następnym wyszukiwaniu zapyta ponownie.", - "System Lookup App": "Aplikacja wyszukiwania systemowego" + "System Lookup App": "Aplikacja wyszukiwania systemowego", + "Share": "Udostępnij", + "Share text after selection": "Udostępnij tekst po zaznaczeniu", + "Customize Toolbar": "Dostosuj pasek narzędzi", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Przeciągaj narzędzia między wierszami, aby je pokazać lub ukryć i zmienić kolejność paska narzędzi. Możesz też dotknąć narzędzia, aby je przenieść.", + "In toolbar": "Na pasku narzędzi", + "Available": "Dostępne", + "All tools are in the toolbar.": "Wszystkie narzędzia są na pasku narzędzi.", + "Add all": "Dodaj wszystkie", + "Clear all": "Wyczyść wszystkie", + "No tools, drag one here": "Brak narzędzi, przeciągnij jedno tutaj" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 669c91b1..386ab97d 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1745,5 +1745,15 @@ "Reference Pages": "Páginas de referência", "Reference Page Count": "Número de páginas de referência", "Lookup app reset. The next lookup will ask again.": "App de busca redefinido. Na próxima busca, será perguntado novamente.", - "System Lookup App": "App de busca do sistema" + "System Lookup App": "App de busca do sistema", + "Share": "Compartilhar", + "Share text after selection": "Compartilhar texto após a seleção", + "Customize Toolbar": "Personalizar barra de ferramentas", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Arraste as ferramentas entre as linhas para mostrá-las ou ocultá-las e reordenar a barra de ferramentas. Você também pode tocar em uma ferramenta para movê-la.", + "In toolbar": "Na barra de ferramentas", + "Available": "Disponíveis", + "All tools are in the toolbar.": "Todas as ferramentas estão na barra de ferramentas.", + "Add all": "Adicionar tudo", + "Clear all": "Limpar tudo", + "No tools, drag one here": "Sem ferramentas, arraste uma para cá" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 8e401280..f7835982 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1745,5 +1745,15 @@ "Reference Pages": "Páginas de referência", "Reference Page Count": "Número de páginas de referência", "Lookup app reset. The next lookup will ask again.": "App de pesquisa redefinida. Na próxima pesquisa será perguntado novamente.", - "System Lookup App": "App de pesquisa do sistema" + "System Lookup App": "App de pesquisa do sistema", + "Share": "Partilhar", + "Share text after selection": "Partilhar texto após a seleção", + "Customize Toolbar": "Personalizar barra de ferramentas", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Arraste as ferramentas entre as linhas para as mostrar ou ocultar e reordenar a barra de ferramentas. Também pode tocar numa ferramenta para a mover.", + "In toolbar": "Na barra de ferramentas", + "Available": "Disponíveis", + "All tools are in the toolbar.": "Todas as ferramentas estão na barra de ferramentas.", + "Add all": "Adicionar tudo", + "Clear all": "Limpar tudo", + "No tools, drag one here": "Sem ferramentas, arraste uma para aqui" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 0f812330..69da034a 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1745,5 +1745,15 @@ "Reference Pages": "Pagini de referință", "Reference Page Count": "Numărul paginilor de referință", "Lookup app reset. The next lookup will ask again.": "Aplicația de căutare a fost resetată. La următoarea căutare se va întreba din nou.", - "System Lookup App": "Aplicație de căutare a sistemului" + "System Lookup App": "Aplicație de căutare a sistemului", + "Share": "Partajează", + "Share text after selection": "Partajează textul după selectare", + "Customize Toolbar": "Personalizează bara de instrumente", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Trage instrumentele între rânduri pentru a le afișa sau ascunde și pentru a reordona bara de instrumente. Poți de asemenea să atingi un instrument pentru a-l muta.", + "In toolbar": "În bara de instrumente", + "Available": "Disponibile", + "All tools are in the toolbar.": "Toate instrumentele sunt în bara de instrumente.", + "Add all": "Adaugă tot", + "Clear all": "Șterge tot", + "No tools, drag one here": "Niciun instrument, trage unul aici" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index ec6e595b..a8096c14 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1778,5 +1778,15 @@ "Reference Pages": "Страницы бумажной книги", "Reference Page Count": "Число страниц бумажной книги", "Lookup app reset. The next lookup will ask again.": "Приложение для поиска сброшено. При следующем поиске запрос появится снова.", - "System Lookup App": "Системное приложение для поиска" + "System Lookup App": "Системное приложение для поиска", + "Share": "Поделиться", + "Share text after selection": "Поделиться текстом после выделения", + "Customize Toolbar": "Настроить панель инструментов", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Перетаскивайте инструменты между строками, чтобы показать или скрыть их и изменить порядок на панели инструментов. Также можно коснуться инструмента, чтобы переместить его.", + "In toolbar": "На панели инструментов", + "Available": "Доступные", + "All tools are in the toolbar.": "Все инструменты на панели инструментов.", + "Add all": "Добавить все", + "Clear all": "Очистить все", + "No tools, drag one here": "Нет инструментов, перетащите один сюда" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index c06f7202..f53e3521 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "යොමු පිටු", "Reference Page Count": "යොමු පිටු ගණන", "Lookup app reset. The next lookup will ask again.": "සෙවුම් යෙදුම යළි සකසන ලදී. ඊළඟ සෙවුමේදී නැවත අසනු ඇත.", - "System Lookup App": "පද්ධති සෙවුම් යෙදුම" + "System Lookup App": "පද්ධති සෙවුම් යෙදුම", + "Share": "බෙදාගන්න", + "Share text after selection": "තේරීමෙන් පසු පෙළ බෙදාගන්න", + "Customize Toolbar": "මෙවලම් තීරුව අභිරුචිකරණය කරන්න", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "මෙවලම් පේළි අතර ඇද දමා ඒවා පෙන්වන්න හෝ සඟවන්න සහ මෙවලම් තීරුව නැවත පිළිවෙළට සකසන්න. මෙවලමක් ගෙනයාමට ඔබට එය මත තට්ටු කළ හැක.", + "In toolbar": "මෙවලම් තීරුවේ", + "Available": "ලබා ගත හැකි", + "All tools are in the toolbar.": "සියලුම මෙවලම් මෙවලම් තීරුවේ ඇත.", + "Add all": "සියල්ල එක් කරන්න", + "Clear all": "සියල්ල හිස් කරන්න", + "No tools, drag one here": "මෙවලම් නැත, මෙතැනට එකක් ඇද දමන්න" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 597d9472..f7326349 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1778,5 +1778,15 @@ "Reference Pages": "Referenčne strani", "Reference Page Count": "Število referenčnih strani", "Lookup app reset. The next lookup will ask again.": "Aplikacija za iskanje je ponastavljena. Ob naslednjem iskanju bo znova vprašala.", - "System Lookup App": "Sistemska aplikacija za iskanje" + "System Lookup App": "Sistemska aplikacija za iskanje", + "Share": "Deli", + "Share text after selection": "Deli besedilo po izbiri", + "Customize Toolbar": "Prilagodi orodno vrstico", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Povlecite orodja med vrsticami, da jih prikažete ali skrijete in prerazporedite orodno vrstico. Orodje lahko tudi tapnete, da ga premaknete.", + "In toolbar": "V orodni vrstici", + "Available": "Na voljo", + "All tools are in the toolbar.": "Vsa orodja so v orodni vrstici.", + "Add all": "Dodaj vse", + "Clear all": "Počisti vse", + "No tools, drag one here": "Ni orodij, povlecite eno sem" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 4cbcbc0f..7411e45b 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "Referenssidor", "Reference Page Count": "Antal referenssidor", "Lookup app reset. The next lookup will ask again.": "Uppslagsappen återställd. Nästa sökning frågar igen.", - "System Lookup App": "Systemets uppslagsapp" + "System Lookup App": "Systemets uppslagsapp", + "Share": "Dela", + "Share text after selection": "Dela text efter markering", + "Customize Toolbar": "Anpassa verktygsfältet", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Dra verktyg mellan raderna för att visa eller dölja dem och ändra ordningen i verktygsfältet. Du kan även trycka på ett verktyg för att flytta det.", + "In toolbar": "I verktygsfältet", + "Available": "Tillgängliga", + "All tools are in the toolbar.": "Alla verktyg finns i verktygsfältet.", + "Add all": "Lägg till alla", + "Clear all": "Rensa alla", + "No tools, drag one here": "Inga verktyg, dra ett hit" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 4d4968df..231a5567 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "குறிப்புப் பக்கங்கள்", "Reference Page Count": "குறிப்புப் பக்கங்களின் எண்ணிக்கை", "Lookup app reset. The next lookup will ask again.": "தேடல் செயலி மீட்டமைக்கப்பட்டது. அடுத்த தேடலில் மீண்டும் கேட்கப்படும்.", - "System Lookup App": "கணினி தேடல் செயலி" + "System Lookup App": "கணினி தேடல் செயலி", + "Share": "பகிர்", + "Share text after selection": "தேர்வுக்குப் பிறகு உரையைப் பகிர்", + "Customize Toolbar": "கருவிப்பட்டியைத் தனிப்பயனாக்கு", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "கருவிகளை வரிசைகளுக்கு இடையே இழுத்து அவற்றைக் காட்டலாம் அல்லது மறைக்கலாம் மற்றும் கருவிப்பட்டியை மறுவரிசைப்படுத்தலாம். ஒரு கருவியை நகர்த்த அதைத் தட்டலாம்.", + "In toolbar": "கருவிப்பட்டியில்", + "Available": "கிடைக்கின்றன", + "All tools are in the toolbar.": "அனைத்து கருவிகளும் கருவிப்பட்டியில் உள்ளன.", + "Add all": "அனைத்தையும் சேர்", + "Clear all": "அனைத்தையும் அழி", + "No tools, drag one here": "கருவிகள் இல்லை, ஒன்றை இங்கே இழுக்கவும்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 44aa8d9c..b0f743fe 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "หน้าอ้างอิง", "Reference Page Count": "จำนวนหน้าอ้างอิง", "Lookup app reset. The next lookup will ask again.": "รีเซ็ตแอปค้นหาแล้ว การค้นหาครั้งถัดไปจะถามอีกครั้ง", - "System Lookup App": "แอปค้นหาของระบบ" + "System Lookup App": "แอปค้นหาของระบบ", + "Share": "แชร์", + "Share text after selection": "แชร์ข้อความหลังการเลือก", + "Customize Toolbar": "ปรับแต่งแถบเครื่องมือ", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "ลากเครื่องมือระหว่างแถวเพื่อแสดงหรือซ่อน และจัดเรียงแถบเครื่องมือใหม่ คุณยังสามารถแตะเครื่องมือเพื่อย้ายได้", + "In toolbar": "ในแถบเครื่องมือ", + "Available": "พร้อมใช้งาน", + "All tools are in the toolbar.": "เครื่องมือทั้งหมดอยู่ในแถบเครื่องมือแล้ว", + "Add all": "เพิ่มทั้งหมด", + "Clear all": "ล้างทั้งหมด", + "No tools, drag one here": "ไม่มีเครื่องมือ ลากมาที่นี่สักรายการ" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 6983590f..ca25e204 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "Referans sayfaları", "Reference Page Count": "Referans sayfa sayısı", "Lookup app reset. The next lookup will ask again.": "Arama uygulaması sıfırlandı. Sonraki aramada tekrar sorulacak.", - "System Lookup App": "Sistem arama uygulaması" + "System Lookup App": "Sistem arama uygulaması", + "Share": "Paylaş", + "Share text after selection": "Seçimden sonra metni paylaş", + "Customize Toolbar": "Araç Çubuğunu Özelleştir", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Araçları satırlar arasında sürükleyerek gösterin veya gizleyin ve araç çubuğunu yeniden sıralayın. Bir aracı taşımak için ona dokunabilirsiniz de.", + "In toolbar": "Araç çubuğunda", + "Available": "Kullanılabilir", + "All tools are in the toolbar.": "Tüm araçlar araç çubuğunda.", + "Add all": "Tümünü ekle", + "Clear all": "Tümünü temizle", + "No tools, drag one here": "Araç yok, buraya bir tane sürükleyin" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index d5faa2d7..fe6c7d0b 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1778,5 +1778,15 @@ "Reference Pages": "Сторінки паперової книги", "Reference Page Count": "Кількість сторінок паперової книги", "Lookup app reset. The next lookup will ask again.": "Застосунок для пошуку скинуто. Під час наступного пошуку буде запит знову.", - "System Lookup App": "Системний застосунок для пошуку" + "System Lookup App": "Системний застосунок для пошуку", + "Share": "Поділитися", + "Share text after selection": "Поділитися текстом після виділення", + "Customize Toolbar": "Налаштувати панель інструментів", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Перетягуйте інструменти між рядками, щоб показати чи приховати їх і змінити порядок на панелі інструментів. Також можна торкнутися інструмента, щоб перемістити його.", + "In toolbar": "На панелі інструментів", + "Available": "Доступні", + "All tools are in the toolbar.": "Усі інструменти на панелі інструментів.", + "Add all": "Додати все", + "Clear all": "Очистити все", + "No tools, drag one here": "Немає інструментів, перетягніть один сюди" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 567fb511..8c495707 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1712,5 +1712,15 @@ "Reference Pages": "Bosma sahifalar", "Reference Page Count": "Bosma sahifalar soni", "Lookup app reset. The next lookup will ask again.": "Qidiruv ilovasi tiklandi. Keyingi qidiruvda yana so‘raladi.", - "System Lookup App": "Tizim qidiruv ilovasi" + "System Lookup App": "Tizim qidiruv ilovasi", + "Share": "Ulashish", + "Share text after selection": "Tanlovdan keyin matnni ulashish", + "Customize Toolbar": "Asboblar panelini moslashtirish", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Asboblarni qatorlar orasida tortib, ularni koʻrsating yoki yashiring va asboblar panelini qayta tartiblang. Asbobni koʻchirish uchun unga bosishingiz ham mumkin.", + "In toolbar": "Asboblar panelida", + "Available": "Mavjud", + "All tools are in the toolbar.": "Barcha asboblar asboblar panelida.", + "Add all": "Hammasini qoʻshish", + "Clear all": "Hammasini tozalash", + "No tools, drag one here": "Asboblar yoʻq, bittasini shu yerga torting" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 2a025b9d..f41e0b9e 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "Trang tham chiếu", "Reference Page Count": "Số trang tham chiếu", "Lookup app reset. The next lookup will ask again.": "Đã đặt lại ứng dụng tra cứu. Lần tra cứu tiếp theo sẽ hỏi lại.", - "System Lookup App": "Ứng dụng tra cứu hệ thống" + "System Lookup App": "Ứng dụng tra cứu hệ thống", + "Share": "Chia sẻ", + "Share text after selection": "Chia sẻ văn bản sau khi chọn", + "Customize Toolbar": "Tùy chỉnh thanh công cụ", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "Kéo các công cụ giữa các hàng để hiển thị hoặc ẩn chúng và sắp xếp lại thanh công cụ. Bạn cũng có thể chạm vào một công cụ để di chuyển nó.", + "In toolbar": "Trên thanh công cụ", + "Available": "Khả dụng", + "All tools are in the toolbar.": "Tất cả công cụ đều ở trên thanh công cụ.", + "Add all": "Thêm tất cả", + "Clear all": "Xóa tất cả", + "No tools, drag one here": "Không có công cụ, hãy kéo một công cụ vào đây" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index ce28206d..be004267 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "参考页码", "Reference Page Count": "参考总页数", "Lookup app reset. The next lookup will ask again.": "已重置查词应用。下次查词时将再次询问。", - "System Lookup App": "系统查词应用" + "System Lookup App": "系统查词应用", + "Share": "分享", + "Share text after selection": "选择后分享文本", + "Customize Toolbar": "自定义工具栏", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "在两行之间拖动工具即可显示或隐藏它们并重新排列工具栏。你也可以点按某个工具来移动它。", + "In toolbar": "工具栏中", + "Available": "可用", + "All tools are in the toolbar.": "所有工具都在工具栏中。", + "Add all": "全部添加", + "Clear all": "全部清除", + "No tools, drag one here": "没有工具,拖一个到这里" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index b0f9468e..364d7328 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1679,5 +1679,15 @@ "Reference Pages": "參考頁碼", "Reference Page Count": "參考總頁數", "Lookup app reset. The next lookup will ask again.": "已重設查詞應用程式。下次查詞時將再次詢問。", - "System Lookup App": "系統查詞應用程式" + "System Lookup App": "系統查詞應用程式", + "Share": "分享", + "Share text after selection": "選取後分享文字", + "Customize Toolbar": "自訂工具列", + "Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.": "在兩列之間拖曳工具即可顯示或隱藏它們並重新排列工具列。你也可以輕觸某個工具來移動它。", + "In toolbar": "工具列中", + "Available": "可用", + "All tools are in the toolbar.": "所有工具都在工具列中。", + "Add all": "全部新增", + "Clear all": "全部清除", + "No tools, drag one here": "沒有工具,拖一個到這裡" } diff --git a/apps/readest-app/src/__tests__/components/annotation-popup-layout.browser.test.tsx b/apps/readest-app/src/__tests__/components/annotation-popup-layout.browser.test.tsx index 65d682a6..5fcec633 100644 --- a/apps/readest-app/src/__tests__/components/annotation-popup-layout.browser.test.tsx +++ b/apps/readest-app/src/__tests__/components/annotation-popup-layout.browser.test.tsx @@ -80,6 +80,7 @@ vi.mock('@/app/reader/utils/annotatorUtil', () => ({ import AnnotationPopup from '@/app/reader/components/annotator/AnnotationPopup'; import { annotationToolButtons } from '@/app/reader/components/annotator/AnnotationTools'; +import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar'; // ── Constants ─────────────────────────────────────────────────────────── @@ -96,11 +97,15 @@ const POPUP_Y = OPTIONS_OFFSET; const POPUP_X = 0; const WRAPPER_H = POPUP_Y + POPUP_H + 14; // +14 for triangle below -const toolButtons = annotationToolButtons.map(({ label, Icon }) => ({ - tooltipText: label, - Icon, - onClick: vi.fn(), -})); +// Render the default-enabled tools (Share is hidden by default; users add it +// via Customize Toolbar), matching what the popup shows out of the box. +const toolButtons = annotationToolButtons + .filter((button) => DEFAULT_ANNOTATION_TOOLBAR_ITEMS.includes(button.type)) + .map(({ label, Icon }) => ({ + tooltipText: label, + Icon, + onClick: vi.fn(), + })); // Browser-mode matcher types are unavailable to tsgo; cast once here. const expectElement = (locator: unknown) => diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts index dc1baddf..ca565fa3 100644 --- a/apps/readest-app/src/__tests__/services/constants.test.ts +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar'; vi.mock('@/utils/config', () => ({ getDefaultMaxBlockSize: vi.fn(() => 1600), @@ -710,6 +711,13 @@ describe('services/constants', () => { expect(DEFAULT_ANNOTATOR_CONFIG.noteExportConfig).toBeDefined(); expect(DEFAULT_ANNOTATOR_CONFIG.noteExportConfig).toBe(DEFAULT_NOTE_EXPORT_CONFIG); }); + + it('annotationToolbarItems defaults to the eight non-share tools', () => { + expect(DEFAULT_ANNOTATOR_CONFIG.annotationToolbarItems).toEqual( + DEFAULT_ANNOTATION_TOOLBAR_ITEMS, + ); + expect(DEFAULT_ANNOTATOR_CONFIG.annotationToolbarItems).not.toContain('share'); + }); }); // --------------------------------------------------------------------------- diff --git a/apps/readest-app/src/__tests__/utils/annotationToolbar.test.ts b/apps/readest-app/src/__tests__/utils/annotationToolbar.test.ts new file mode 100644 index 00000000..62b66503 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/annotationToolbar.test.ts @@ -0,0 +1,85 @@ +import { describe, test, expect } from 'vitest'; +import { annotationToolButtons } from '@/app/reader/components/annotator/AnnotationTools'; +import { + ALL_ANNOTATION_TOOL_TYPES, + DEFAULT_ANNOTATION_TOOLBAR_ITEMS, + getToolbarToolTypes, + getAvailableToolTypes, + addToolToToolbar, + removeToolFromToolbar, + reorderToolbar, +} from '@/utils/annotationToolbar'; + +describe('annotationToolbar helpers', () => { + test('ALL_ANNOTATION_TOOL_TYPES matches the button registry order', () => { + expect(ALL_ANNOTATION_TOOL_TYPES).toEqual(annotationToolButtons.map((b) => b.type)); + }); + + test('default toolbar is the eight non-share tools in canonical order', () => { + expect(DEFAULT_ANNOTATION_TOOLBAR_ITEMS).toEqual([ + 'copy', + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', + ]); + expect(DEFAULT_ANNOTATION_TOOLBAR_ITEMS).not.toContain('share'); + }); + + test('getToolbarToolTypes preserves order and falls back to default when undefined', () => { + expect(getToolbarToolTypes(undefined, true)).toEqual(DEFAULT_ANNOTATION_TOOLBAR_ITEMS); + expect(getToolbarToolTypes(['search', 'copy'], true)).toEqual(['search', 'copy']); + }); + + test('getToolbarToolTypes drops share when !canShare, keeps it when canShare', () => { + expect(getToolbarToolTypes(['copy', 'share'], false)).toEqual(['copy']); + expect(getToolbarToolTypes(['copy', 'share'], true)).toEqual(['copy', 'share']); + }); + + test('getToolbarToolTypes drops unknown/duplicate entries', () => { + expect(getToolbarToolTypes(['copy', 'copy', 'bogus' as never], true)).toEqual(['copy']); + }); + + test('getAvailableToolTypes returns canonical-order complement', () => { + expect(getAvailableToolTypes(['copy'], true)).toEqual([ + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', + 'share', + ]); + }); + + test('getAvailableToolTypes hides share when !canShare', () => { + expect(getAvailableToolTypes(['copy'], false)).not.toContain('share'); + }); + + test('addToolToToolbar appends by default and is a no-op when present', () => { + expect(addToolToToolbar(['copy'], 'share')).toEqual(['copy', 'share']); + expect(addToolToToolbar(['copy', 'share'], 'share')).toEqual(['copy', 'share']); + }); + + test('addToolToToolbar inserts at the given index', () => { + expect(addToolToToolbar(['copy', 'search'], 'share', 1)).toEqual(['copy', 'share', 'search']); + }); + + test('removeToolFromToolbar removes the tool', () => { + expect(removeToolFromToolbar(['copy', 'share'], 'share')).toEqual(['copy']); + expect(removeToolFromToolbar(['copy'], 'share')).toEqual(['copy']); + }); + + test('reorderToolbar moves a tool to another tool position', () => { + expect(reorderToolbar(['copy', 'highlight', 'search'], 'search', 'copy')).toEqual([ + 'search', + 'copy', + 'highlight', + ]); + expect(reorderToolbar(['copy', 'search'], 'copy', 'copy')).toEqual(['copy', 'search']); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts b/apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts new file mode 100644 index 00000000..92fe54da --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/shareSelectedText.test.ts @@ -0,0 +1,113 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +const shareTextMock = vi.fn().mockResolvedValue(undefined); +const writeClipboardMock = vi.fn().mockResolvedValue(undefined); + +vi.mock('@choochmeque/tauri-plugin-sharekit-api', () => ({ + shareText: (...args: unknown[]) => shareTextMock(...args), +})); + +vi.mock('@/utils/clipboard', () => ({ + writeTextToClipboard: (...args: unknown[]) => writeClipboardMock(...args), +})); + +import { canShareText, shareSelectedText } from '@/utils/share'; + +describe('shareSelectedText', () => { + beforeEach(() => { + shareTextMock.mockClear().mockResolvedValue(undefined); + writeClipboardMock.mockClear().mockResolvedValue(undefined); + // @ts-expect-error - reset between tests + delete globalThis.navigator.share; + }); + + afterEach(() => { + // @ts-expect-error - cleanup + delete globalThis.navigator.share; + }); + + test('no-op on empty text', async () => { + await shareSelectedText('', undefined, { isMobileApp: true }); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('uses native shareText on mobile', async () => { + await shareSelectedText('hello', { x: 1, y: 2 }, { isMobileApp: true }); + expect(shareTextMock).toHaveBeenCalledWith('hello', { position: { x: 1, y: 2 } }); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('uses native shareText on macOS desktop', async () => { + await shareSelectedText('hello', undefined, { isMacOSApp: true }); + expect(shareTextMock).toHaveBeenCalledTimes(1); + }); + + test('does NOT use native shareText on Windows/Linux; falls to navigator.share', async () => { + const navShare = vi.fn().mockResolvedValue(undefined); + globalThis.navigator.share = navShare; + // A desktop platform that is neither mobile nor macOS (e.g. Windows/Linux): + // native sharekit is skipped (issue #4343) and we fall to the Web Share API. + await shareSelectedText('hello', undefined, { isMobileApp: false, isMacOSApp: false }); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(navShare).toHaveBeenCalledWith({ text: 'hello' }); + }); + + test('falls back to navigator.share when not a native share platform', async () => { + const navShare = vi.fn().mockResolvedValue(undefined); + globalThis.navigator.share = navShare; + await shareSelectedText('hello', undefined, null); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(navShare).toHaveBeenCalledWith({ text: 'hello' }); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('swallows navigator.share rejection (user dismissed) without clipboard fallback', async () => { + const navShare = vi.fn().mockRejectedValue(new Error('AbortError')); + globalThis.navigator.share = navShare; + await expect(shareSelectedText('hello', undefined, null)).resolves.toBeUndefined(); + expect(writeClipboardMock).not.toHaveBeenCalled(); + }); + + test('falls back to clipboard when no share method exists', async () => { + await shareSelectedText('hello', undefined, null); + expect(shareTextMock).not.toHaveBeenCalled(); + expect(writeClipboardMock).toHaveBeenCalledWith('hello'); + }); + + test('falls back to navigator.share when native shareText throws', async () => { + shareTextMock.mockRejectedValueOnce(new Error('plugin unavailable')); + const navShare = vi.fn().mockResolvedValue(undefined); + globalThis.navigator.share = navShare; + await shareSelectedText('hello', undefined, { isMobileApp: true }); + expect(navShare).toHaveBeenCalledWith({ text: 'hello' }); + }); +}); + +describe('canShareText', () => { + beforeEach(() => { + // @ts-expect-error - reset between tests + delete globalThis.navigator.share; + }); + + afterEach(() => { + // @ts-expect-error - cleanup + delete globalThis.navigator.share; + }); + + test('true on mobile and macOS', () => { + expect(canShareText({ isMobileApp: true })).toBe(true); + expect(canShareText({ isMacOSApp: true })).toBe(true); + }); + + test('true when the Web Share API is present', () => { + globalThis.navigator.share = vi.fn().mockResolvedValue(undefined); + expect(canShareText({ isMobileApp: false, isMacOSApp: false })).toBe(true); + expect(canShareText(null)).toBe(true); + }); + + test('false on desktop without the Web Share API', () => { + expect(canShareText({ isMobileApp: false, isMacOSApp: false })).toBe(false); + expect(canShareText(null)).toBe(false); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx index 2e477668..3c505376 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationTools.tsx @@ -1,6 +1,7 @@ import { IconType } from 'react-icons'; import { FiSearch } from 'react-icons/fi'; import { FiCopy } from 'react-icons/fi'; +import { FiShare } from 'react-icons/fi'; import { PiHighlighterFill } from 'react-icons/pi'; import { BsPencilSquare } from 'react-icons/bs'; import { BsTranslate } from 'react-icons/bs'; @@ -89,6 +90,13 @@ export const annotationToolButtons = createAnnotationToolButtons([ tooltip: _('Proofread text after selection'), Icon: IoIosBuild, }, + { + type: 'share', + label: _('Share'), + tooltip: _('Share text after selection'), + Icon: FiShare, + quickAction: true, + }, ]); export const annotationToolQuickActions = annotationToolButtons.filter( diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index dda12d6a..e20ad93b 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -48,6 +48,9 @@ import { runSimpleCC } from '@/utils/simplecc'; import { getWordCount } from '@/utils/word'; import { getIndexFromCfi } from '@/utils/cfi'; import { writeTextToClipboard } from '@/utils/clipboard'; +import { canShareText, shareSelectedText } from '@/utils/share'; +import { getToolbarToolTypes } from '@/utils/annotationToolbar'; +import { AnnotationToolType } from '@/types/annotator'; import { TransformContext } from '@/services/transformers/types'; import { transformContent } from '@/services/transformService'; import { @@ -188,7 +191,20 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ const transPopupHeight = Math.min(265, maxHeight); const proofreadPopupWidth = Math.min(440, maxWidth); const proofreadPopupHeight = Math.min(200, maxHeight); - const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth); + const canShare = canShareText(appService); + // The toolbar is now customizable, so size the selection popup to the number + // of visible tools (responsive) up to a max — otherwise a 2-tool toolbar + // renders a sparse, full-width bar. Annotated selections keep the max width + // since they show the wider highlight options / notes instead of the buttons. + const annotPopupMaxWidth = Math.min(useResponsiveSize(300), maxWidth); + const annotPopupToolSize = useResponsiveSize(44); + const visibleToolCount = getToolbarToolTypes( + viewSettings.annotationToolbarItems, + canShare, + ).length; + const annotPopupWidth = selection?.annotated + ? annotPopupMaxWidth + : Math.min(Math.max(visibleToolCount, 1) * annotPopupToolSize, annotPopupMaxWidth); const annotPopupHeight = useResponsiveSize(44); const androidSelectionHandlerHeight = 0; @@ -738,6 +754,9 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ case 'tts': handleSpeakText(true); break; + case 'share': + handleShare(); + break; } }; // On Android, a long-press fires selectionchange (and this handler) while @@ -924,6 +943,19 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ } }; + const handleShare = () => { + if (!selection?.text) return; + const position = trianglePosition + ? { + x: trianglePosition.point.x, + y: trianglePosition.point.y, + preferredEdge: 'bottom' as const, + } + : undefined; + void shareSelectedText(selection.text, position, appService); + handleDismissPopupAndSelection(); + }; + const handleHighlight = (update = false, highlightStyle?: HighlightStyle) => { if (!selection || !selection.text) return; setHighlightOptionsVisible(true); @@ -1446,7 +1478,10 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ !!selection?.text && selection.text.trim().length > 0; const globalToggleActive = !!currentAnnotation?.global; - const toolButtons = annotationToolButtons.map(({ type, label, Icon }) => { + const buildToolButton = (type: AnnotationToolType) => { + const def = annotationToolButtons.find((button) => button.type === type); + if (!def) return null; + const { label, Icon } = def; switch (type) { case 'copy': return { tooltipText: _(label), Icon, onClick: handleCopy }; @@ -1457,27 +1492,15 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ onClick: handleHighlight, }; case 'annotate': - return { - tooltipText: _(label), - Icon, - onClick: handleAnnotate, - }; + return { tooltipText: _(label), Icon, onClick: handleAnnotate }; case 'search': - return { - tooltipText: _(label), - Icon, - onClick: handleSearch, - }; + return { tooltipText: _(label), Icon, onClick: handleSearch }; case 'dictionary': return { tooltipText: _(label), Icon, onClick: handleDictionary }; case 'translate': return { tooltipText: _(label), Icon, onClick: handleTranslation }; case 'tts': - return { - tooltipText: _(label), - Icon, - onClick: handleSpeakText, - }; + return { tooltipText: _(label), Icon, onClick: handleSpeakText }; case 'proofread': return { tooltipText: _(label), @@ -1485,10 +1508,16 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ onClick: handleProofread, disabled: bookData.book?.format !== 'EPUB', }; + case 'share': + return { tooltipText: _(label), Icon, onClick: handleShare }; default: - return { tooltipText: '', Icon, onClick: () => {} }; + return null; } - }); + }; + + const toolButtons = getToolbarToolTypes(viewSettings.annotationToolbarItems, canShare) + .map(buildToolButton) + .filter((button): button is NonNullable => button !== null); return (
@@ -1541,27 +1570,33 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ onDismiss={handleDismissPopupAndSelection} /> )} - {showAnnotPopup && trianglePosition && annotPopupPosition && ( - - )} + {showAnnotPopup && + trianglePosition && + annotPopupPosition && + // With an empty toolbar, suppress the popup on a plain selection rather + // than showing an empty bar. Still allow it for editing an existing + // highlight (options) or viewing its notes. + (toolButtons.length > 0 || highlightOptionsVisible || annotationNotes.length > 0) && ( + + )} {showProofreadPopup && trianglePosition && proofreadPopupPosition && selection && ( void; +} + +const toolButtonOf = (type: AnnotationToolType) => + annotationToolButtons.find((button) => button.type === type); + +interface ToolChipProps { + type: AnnotationToolType; + label: string; + variant: ZoneId; + onActivate: () => void; +} + +const ToolChip: React.FC = ({ type, label, variant, onActivate }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: type, + }); + const Icon = toolButtonOf(type)?.Icon; + const style: React.CSSProperties = { + // `transform` is a relative translate, so the chip tracks the pointer + // correctly even though the settings dialog is a transformed container + // (a `position: fixed` DragOverlay would be offset by that transform). + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 20 : undefined, + opacity: isDragging ? 0.85 : 1, + }; + const isToolbar = variant === 'toolbar'; + return ( + + ); +}; + +const Zone: React.FC<{ + id: ZoneId; + items: AnnotationToolType[]; + emptyHint: string; + renderChip: (type: AnnotationToolType) => React.ReactNode; +}> = ({ id, items, emptyHint, renderChip }) => { + const { setNodeRef } = useDroppable({ id }); + const isToolbar = id === 'toolbar'; + return ( + +
+ {items.length === 0 ? ( + + {emptyHint} + + ) : ( + items.map((type) => {renderChip(type)}) + )} +
+
+ ); +}; + +const AnnotationToolbarCustomizer: React.FC = ({ + bookKey, + onBack, +}) => { + const _ = useTranslation(); + const { envConfig, appService } = useEnv(); + const { getViewSettings } = useReaderStore(); + const { settings } = useSettingsStore(); + const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings; + + const canShare = canShareText(appService); + + // `share` is hidden on platforms that can't share (Windows/Linux desktop). + // If the user enabled it on a share-capable device (e.g. their phone) and it + // synced here, we must not drop it just because the user edits the toolbar on + // this device — preserve it across persists so the capable device keeps it. + const savedHasShare = getToolbarToolTypes(viewSettings.annotationToolbarItems, true).includes( + 'share', + ); + const preserveHiddenShare = !canShare && savedHasShare; + + const [items, setItems] = useState>(() => ({ + toolbar: getToolbarToolTypes(viewSettings.annotationToolbarItems, canShare), + available: getAvailableToolTypes(viewSettings.annotationToolbarItems, canShare), + })); + // dnd-kit invokes onDragEnd with the handler captured at drag start, so the + // closed-over `items` is stale by the time a cross-zone drag finishes. Read + // the live value from this ref instead. Kept in sync on every render. + const itemsRef = useRef(items); + itemsRef.current = items; + // Snapshot taken on drag start so an aborted drag can be fully reverted — + // onDragOver mutates `items` live as the pointer crosses zones. + const beforeDragRef = useRef | null>(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }), + ); + + // closestCorners alone can't reliably target an empty container; prefer the + // droppable under the pointer, and when that's a zone with items, snap to the + // closest chip inside it. (dnd-kit multiple-containers recipe.) + const collisionDetection: CollisionDetection = useCallback( + (args) => { + const pointerCollisions = pointerWithin(args); + const collisions = pointerCollisions.length > 0 ? pointerCollisions : rectIntersection(args); + let overId = getFirstCollision(collisions, 'id'); + if (overId == null) return []; + if (overId === 'toolbar' || overId === 'available') { + const ids = items[overId]; + if (ids.length > 0) { + const inner = closestCorners({ + ...args, + droppableContainers: args.droppableContainers.filter( + (c) => c.id !== overId && ids.includes(c.id as AnnotationToolType), + ), + }); + if (inner.length > 0) overId = inner[0]!.id; + } + } + return [{ id: overId }]; + }, + [items], + ); + + const persist = (toolbar: AnnotationToolType[]) => { + const toSave = + preserveHiddenShare && !toolbar.includes('share') + ? [...toolbar, 'share' as AnnotationToolType] + : toolbar; + saveViewSettings(envConfig, bookKey, 'annotationToolbarItems', toSave, false, true); + }; + + // Commit a new toolbar order: keep the user's arrangement, recompute the + // available tray as its canonical-order complement, and persist. + const commit = (toolbar: AnnotationToolType[]) => { + setItems({ toolbar, available: getAvailableToolTypes(toolbar, canShare) }); + persist(toolbar); + }; + + const zoneOf = (id: string, state: Record): ZoneId | null => { + if (id === 'toolbar' || id === 'available') return id; + if (state.toolbar.includes(id as AnnotationToolType)) return 'toolbar'; + if (state.available.includes(id as AnnotationToolType)) return 'available'; + return null; + }; + + const moveToToolbar = (type: AnnotationToolType) => + commit(addToolToToolbar(itemsRef.current.toolbar, type)); + const moveToAvailable = (type: AnnotationToolType) => + commit(removeToolFromToolbar(itemsRef.current.toolbar, type)); + + // "Add all" rebuilds the toolbar in the canonical predefined order (not the + // user's prior arrangement); "Clear all" empties it. + const addAll = () => commit(ALL_ANNOTATION_TOOL_TYPES.filter((t) => canShare || t !== 'share')); + const clearAll = () => commit([]); + + const handleDragStart = () => { + beforeDragRef.current = items; + }; + + // Live reparent across zones so chips reflow under the cursor. + const handleDragOver = (event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + const activeId = active.id as AnnotationToolType; + const overId = over.id as string; + setItems((prev) => { + const from = zoneOf(activeId, prev); + const to = zoneOf(overId, prev); + if (!from || !to || from === to) return prev; + const fromItems = prev[from].filter((t) => t !== activeId); + const overIndex = prev[to].indexOf(overId as AnnotationToolType); + const insertAt = overIndex >= 0 ? overIndex : prev[to].length; + const toItems = [...prev[to]]; + toItems.splice(insertAt, 0, activeId); + return { ...prev, [from]: fromItems, [to]: toItems } as Record; + }); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + beforeDragRef.current = null; + const current = itemsRef.current; + if (!over) { + commit(current.toolbar); + return; + } + const activeId = active.id as AnnotationToolType; + const overId = over.id as string; + // Reorder within the toolbar (cross-zone moves already applied in onDragOver). + if ( + current.toolbar.includes(activeId) && + overId !== 'toolbar' && + overId !== 'available' && + zoneOf(overId, current) === 'toolbar' + ) { + commit(reorderToolbar(current.toolbar, activeId, overId as AnnotationToolType)); + return; + } + commit(current.toolbar); + }; + + const handleDragCancel = () => { + if (beforeDragRef.current) setItems(beforeDragRef.current); + beforeDragRef.current = null; + }; + + const renderToolbarChip = (type: AnnotationToolType) => ( + moveToAvailable(type)} + /> + ); + const renderAvailableChip = (type: AnnotationToolType) => ( + moveToToolbar(type)} + /> + ); + + return ( +
+ + + +
+ } + /> + + {/* px-4 matches SubPageHeader so the zone labels align with the breadcrumb. */} +
+
+
{_('In toolbar')}
+ +
+
+
{_('Available')}
+ +
+
+
+
+ ); +}; + +export default AnnotationToolbarCustomizer; diff --git a/apps/readest-app/src/components/settings/ControlPanel.tsx b/apps/readest-app/src/components/settings/ControlPanel.tsx index 94cbb871..9ca3dc49 100644 --- a/apps/readest-app/src/components/settings/ControlPanel.tsx +++ b/apps/readest-app/src/components/settings/ControlPanel.tsx @@ -11,9 +11,18 @@ import { getMaxInlineSize } from '@/utils/config'; import { saveSysSettings, saveViewSettings } from '@/helpers/settings'; import { SettingsPanelPanelProp } from './SettingsDialog'; import { annotationToolQuickActions } from '@/app/reader/components/annotator/AnnotationTools'; -import { BoxedList, SettingsRow, SettingsSelect, SettingsSwitchRow } from './primitives'; +import { + BoxedList, + NavigationRow, + SettingsRow, + SettingsSelect, + SettingsSwitchRow, +} from './primitives'; import NumberInput from './NumberInput'; import PageTurnerSettings from './PageTurnerSettings'; +import AnnotationToolbarCustomizer from './AnnotationToolbarCustomizer'; +import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar'; +import { canShareText } from '@/utils/share'; const ControlPanel: React.FC = ({ bookKey, onRegisterReset }) => { const _ = useTranslation(); @@ -44,6 +53,7 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes viewSettings.annotationQuickAction, ); const [copyToNotebook, setCopyToNotebook] = useState(viewSettings.copyToNotebook); + const [showToolbarCustomizer, setShowToolbarCustomizer] = useState(false); const [animated, setAnimated] = useState(viewSettings.animated); const [isEink, setIsEink] = useState(viewSettings.isEink); const [isColorEink, setIsColorEink] = useState(viewSettings.isColorEink); @@ -56,6 +66,7 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes const resetToDefaults = useResetViewSettings(); const pageTurnerResetRef = useRef<() => void>(() => {}); + const canShare = canShareText(appService); const handleReset = () => { resetToDefaults({ @@ -75,6 +86,14 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes enableAnnotationQuickActions: setEnableAnnotationQuickActions, copyToNotebook: setCopyToNotebook, }); + saveViewSettings( + envConfig, + bookKey, + 'annotationToolbarItems', + DEFAULT_ANNOTATION_TOOLBAR_ITEMS, + false, + true, + ); pageTurnerResetRef.current(); }; @@ -237,10 +256,12 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes value: '', label: _('None'), }, - ...annotationToolQuickActions.map((button) => ({ - value: button.type, - label: _(button.label), - })), + ...annotationToolQuickActions + .filter((button) => button.type !== 'share' || canShare) + .map((button) => ({ + value: button.type, + label: _(button.label), + })), ]; }; @@ -250,6 +271,15 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes saveViewSettings(envConfig, bookKey, 'annotationQuickAction', action, false, true); }; + if (showToolbarCustomizer) { + return ( + setShowToolbarCustomizer(false)} + /> + ); + } + return (
@@ -356,6 +386,11 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes onChange={() => setCopyToNotebook(!copyToNotebook)} data-setting-id='settings.control.copyToNotebook' /> + setShowToolbarCustomizer(true)} + data-setting-id='settings.control.customizeToolbar' + /> diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 22c59235..875e1bbf 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -30,6 +30,7 @@ import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota'; import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config'; import { stubTranslation as _ } from '@/utils/misc'; import { DEFAULT_AI_SETTINGS } from './ai/constants'; +import { DEFAULT_ANNOTATION_TOOLBAR_ITEMS } from '@/utils/annotationToolbar'; export const DATA_SUBDIR = 'Readest'; export const LOCAL_BOOKS_SUBDIR = `${DATA_SUBDIR}/Books`; @@ -395,6 +396,7 @@ export const DEFAULT_NOTE_EXPORT_CONFIG: NoteExportConfig = { export const DEFAULT_ANNOTATOR_CONFIG: AnnotatorConfig = { enableAnnotationQuickActions: true, annotationQuickAction: null, + annotationToolbarItems: DEFAULT_ANNOTATION_TOOLBAR_ITEMS, copyToNotebook: false, noteExportConfig: DEFAULT_NOTE_EXPORT_CONFIG, }; diff --git a/apps/readest-app/src/types/annotator.ts b/apps/readest-app/src/types/annotator.ts index da20a98a..7aee5fdb 100644 --- a/apps/readest-app/src/types/annotator.ts +++ b/apps/readest-app/src/types/annotator.ts @@ -6,4 +6,5 @@ export type AnnotationToolType = | 'dictionary' | 'translate' | 'tts' - | 'proofread'; + | 'proofread' + | 'share'; diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index b127d8c1..b949c29f 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -327,6 +327,7 @@ export interface NoteExportConfig { export interface AnnotatorConfig { enableAnnotationQuickActions: boolean; annotationQuickAction: AnnotationToolType | null; + annotationToolbarItems: AnnotationToolType[]; copyToNotebook: boolean; noteExportConfig: NoteExportConfig; } diff --git a/apps/readest-app/src/utils/annotationToolbar.ts b/apps/readest-app/src/utils/annotationToolbar.ts new file mode 100644 index 00000000..6d931c4a --- /dev/null +++ b/apps/readest-app/src/utils/annotationToolbar.ts @@ -0,0 +1,93 @@ +import type { AnnotationToolType } from '@/types/annotator'; + +// Canonical order of every annotation tool. Kept in sync with +// `annotationToolButtons` in AnnotationTools.tsx (asserted by a unit test). +export const ALL_ANNOTATION_TOOL_TYPES: AnnotationToolType[] = [ + 'copy', + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', + 'share', +]; + +// Default toolbar: the eight pre-existing tools in their original order. +// 'share' starts hidden in the Available tray per the #4014 design. +export const DEFAULT_ANNOTATION_TOOLBAR_ITEMS: AnnotationToolType[] = [ + 'copy', + 'highlight', + 'annotate', + 'search', + 'dictionary', + 'translate', + 'tts', + 'proofread', +]; + +// Drop unknown/duplicate entries; fall back to the default when unset (a +// pre-existing per-book config may not carry the field yet). +const sanitize = (items: AnnotationToolType[] | undefined): AnnotationToolType[] => { + const source = items ?? DEFAULT_ANNOTATION_TOOLBAR_ITEMS; + const seen = new Set(); + const out: AnnotationToolType[] = []; + for (const type of source) { + if (ALL_ANNOTATION_TOOL_TYPES.includes(type) && !seen.has(type)) { + seen.add(type); + out.push(type); + } + } + return out; +}; + +// Visible tools to render in the live selection toolbar, in order. +export const getToolbarToolTypes = ( + items: AnnotationToolType[] | undefined, + canShare: boolean, +): AnnotationToolType[] => sanitize(items).filter((type) => canShare || type !== 'share'); + +// Hidden tools (the "Available" tray), in canonical order. +export const getAvailableToolTypes = ( + items: AnnotationToolType[] | undefined, + canShare: boolean, +): AnnotationToolType[] => { + const visible = new Set(sanitize(items)); + return ALL_ANNOTATION_TOOL_TYPES.filter( + (type) => !visible.has(type) && (canShare || type !== 'share'), + ); +}; + +// Add `type` to the visible list at `atIndex` (default: end). No-op if present. +export const addToolToToolbar = ( + visible: AnnotationToolType[], + type: AnnotationToolType, + atIndex?: number, +): AnnotationToolType[] => { + if (visible.includes(type)) return visible; + const next = [...visible]; + next.splice(atIndex ?? next.length, 0, type); + return next; +}; + +// Remove `type` from the visible list. No-op if absent. +export const removeToolFromToolbar = ( + visible: AnnotationToolType[], + type: AnnotationToolType, +): AnnotationToolType[] => visible.filter((type_) => type_ !== type); + +// Move `fromType` to where `toType` currently sits within the visible list. +export const reorderToolbar = ( + visible: AnnotationToolType[], + fromType: AnnotationToolType, + toType: AnnotationToolType, +): AnnotationToolType[] => { + const from = visible.indexOf(fromType); + const to = visible.indexOf(toType); + if (from < 0 || to < 0 || from === to) return visible; + const next = [...visible]; + const spliced = next.splice(from, 1); + next.splice(to, 0, spliced[0]!); + return next; +}; diff --git a/apps/readest-app/src/utils/share.ts b/apps/readest-app/src/utils/share.ts index b75e9206..cc8b2897 100644 --- a/apps/readest-app/src/utils/share.ts +++ b/apps/readest-app/src/utils/share.ts @@ -1,3 +1,4 @@ +import { writeTextToClipboard } from '@/utils/clipboard'; import { READEST_WEB_BASE_URL, SHARE_BASE_URL, SHARE_TOKEN_LENGTH } from '@/services/constants'; export interface ShareDeepLink { @@ -44,6 +45,69 @@ export const parseShareDeepLink = (url: string): ShareDeepLink | null => { return null; }; +export interface SharePosition { + x: number; + y: number; + preferredEdge?: 'top' | 'bottom' | 'left' | 'right'; +} + +/** Minimal slice of AppService needed to decide the native-share path. */ +interface ShareCapableService { + isMobileApp?: boolean; + isMacOSApp?: boolean; +} + +/** + * Whether the selected text can be shared by ANY method on this platform — + * native sharekit (mobile/macOS) or the Web Share API. Used to gate the Share + * tool's visibility in the selection toolbar and its customizer. Kept next to + * `shareSelectedText` so the two stay in sync. + */ +export const canShareText = (appService?: ShareCapableService | null): boolean => + !!appService?.isMobileApp || + !!appService?.isMacOSApp || + (typeof navigator !== 'undefined' && typeof navigator.share === 'function'); + +/** + * Open the OS share sheet for `text`, with graceful fallbacks. + * + * Ladder: + * 1. Native sharekit on mobile + macOS only. Windows/Linux are excluded: the + * plugin's share UI can freeze the app on Windows (issue #4343) and is not + * functional on Linux — `nativeAppService` gates `shareFile` the same way. + * 2. `navigator.share` (web / PWA). A rejection means the user dismissed the + * sheet — respect it, don't silently copy. + * 3. Clipboard, as a last resort when no share method exists. + */ +export const shareSelectedText = async ( + text: string, + position?: SharePosition, + appService?: ShareCapableService | null, +): Promise => { + if (!text) return; + + if (appService?.isMobileApp || appService?.isMacOSApp) { + try { + const { shareText } = await import('@choochmeque/tauri-plugin-sharekit-api'); + await shareText(text, { position }); + return; + } catch (err) { + console.error('shareText failed; falling back:', err); + } + } + + if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { + try { + await navigator.share({ text }); + } catch { + // User dismissed or share-time error; respect the choice. + } + return; + } + + await writeTextToClipboard(text); +}; + const isWebReadestHost = (host: string): boolean => { // Matches the production host and any preview domain Readest may serve from. // Conservative: accepts only the exact production host or a *.readest.com