feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570)

* docs(spec): annotation Share tool + customizable toolbar (#4014)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): implementation plan for Share tool + customizable toolbar (#4014)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* feat(annotator): render Share tool and honor toolbar order in selection popup (#4014)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings): add drag-and-drop annotation toolbar customizer (#4014)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings): open the toolbar customizer from the Behavior panel (#4014)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(i18n): extract and translate annotation share/toolbar strings (#4014)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-13 20:11:59 +08:00
committed by GitHub
parent b6937f43f1
commit 67c22c770b
48 changed files with 2496 additions and 84 deletions
File diff suppressed because it is too large Load Diff
@@ -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
`<SubPage onBack={...} />`, 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
`<AnnotationToolbarCustomizer onBack={() => 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).
@@ -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": "لا توجد أدوات، اسحب واحدة إلى هنا"
}
@@ -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": "কোনো টুল নেই, এখানে একটি টেনে আনুন"
}
@@ -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": "ལག་ཆ་མེད། འདིར་གཅིག་འདྲུད་རོགས།"
}
@@ -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"
}
@@ -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": "Δεν υπάρχουν εργαλεία, σύρετε ένα εδώ"
}
@@ -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í"
}
@@ -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": "هیچ ابزاری نیست، یکی را به اینجا بکشید"
}
@@ -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 doutils",
"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 doutils. Vous pouvez aussi toucher un outil pour le déplacer.",
"In toolbar": "Dans la barre doutils",
"Available": "Disponibles",
"All tools are in the toolbar.": "Tous les outils sont dans la barre doutils.",
"Add all": "Tout ajouter",
"Clear all": "Tout effacer",
"No tools, drag one here": "Aucun outil, glissez-en un ici"
}
@@ -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": "אין כלים, גררו אחד לכאן"
}
@@ -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": "कोई टूल नहीं, यहाँ एक खींचें"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": "ツールがありません、ここにドラッグしてください"
}
@@ -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": "도구가 없습니다, 여기로 하나를 드래그하세요"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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á"
}
@@ -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"
}
@@ -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"
}
@@ -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": "Нет инструментов, перетащите один сюда"
}
@@ -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": "මෙවලම් නැත, මෙතැනට එකක් ඇද දමන්න"
}
@@ -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"
}
@@ -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"
}
@@ -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": "கருவிகள் இல்லை, ஒன்றை இங்கே இழுக்கவும்"
}
@@ -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": "ไม่มีเครื่องมือ ลากมาที่นี่สักรายการ"
}
@@ -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"
}
@@ -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": "Немає інструментів, перетягніть один сюди"
}
@@ -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 soraladi.",
"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"
}
@@ -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"
}
@@ -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": "没有工具,拖一个到这里"
}
@@ -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": "沒有工具,拖一個到這裡"
}
@@ -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) =>
@@ -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');
});
});
// ---------------------------------------------------------------------------
@@ -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']);
});
});
@@ -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);
});
});
@@ -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(
@@ -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<typeof button> => button !== null);
return (
<div ref={containerRef} role='toolbar' tabIndex={-1}>
@@ -1541,27 +1570,33 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showAnnotPopup && trianglePosition && annotPopupPosition && (
<AnnotationPopup
bookKey={bookKey}
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
isVertical={viewSettings.vertical}
buttons={toolButtons}
notes={annotationNotes}
position={annotPopupPosition}
trianglePosition={trianglePosition}
highlightOptionsVisible={highlightOptionsVisible}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
popupWidth={annotPopupWidth}
popupHeight={annotPopupHeight}
globalToggleAvailable={globalToggleAvailable}
globalToggleActive={globalToggleActive}
onToggleGlobal={handleToggleGlobal}
onHighlight={handleHighlight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
{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) && (
<AnnotationPopup
bookKey={bookKey}
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
isVertical={viewSettings.vertical}
buttons={toolButtons}
notes={annotationNotes}
position={annotPopupPosition}
trianglePosition={trianglePosition}
highlightOptionsVisible={highlightOptionsVisible}
selectedStyle={selectedStyle}
selectedColor={selectedColor}
popupWidth={annotPopupWidth}
popupHeight={annotPopupHeight}
globalToggleAvailable={globalToggleAvailable}
globalToggleActive={globalToggleActive}
onToggleGlobal={handleToggleGlobal}
onHighlight={handleHighlight}
onDismiss={handleDismissPopupAndSelection}
/>
)}
{showProofreadPopup && trianglePosition && proofreadPopupPosition && selection && (
<ProofreadPopup
bookKey={bookKey}
@@ -0,0 +1,364 @@
import clsx from 'clsx';
import React, { useCallback, useRef, useState } from 'react';
import {
DndContext,
PointerSensor,
TouchSensor,
closestCorners,
getFirstCollision,
pointerWithin,
rectIntersection,
useDroppable,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragOverEvent,
} from '@dnd-kit/core';
import { SortableContext, rectSortingStrategy, 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 {
ALL_ANNOTATION_TOOL_TYPES,
getAvailableToolTypes,
getToolbarToolTypes,
addToolToToolbar,
removeToolFromToolbar,
reorderToolbar,
} from '@/utils/annotationToolbar';
import { canShareText } from '@/utils/share';
import SubPageHeader from './SubPageHeader';
type ZoneId = 'toolbar' | 'available';
interface AnnotationToolbarCustomizerProps {
bookKey: string;
onBack: () => 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<ToolChipProps> = ({ 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 (
<button
ref={setNodeRef}
type='button'
style={style}
// Tap = move between zones; press-and-drag = reorder/move (the sensors'
// activation constraints distinguish the two). Keeps the action usable
// on e-ink and for keyboard/AT users where drag is impractical.
onClick={onActivate}
className={clsx(
'flex cursor-grab touch-none select-none items-center active:cursor-grabbing',
isToolbar
? // Mirror the live toolbar's AnnotationToolButton: icon-only 32×32.
'h-8 min-h-8 w-8 justify-center rounded-md p-0 not-eink:hover:bg-gray-500 eink:hover:border'
: // Available tools are labeled so they're identifiable off the bar.
'eink-bordered border-base-300 bg-base-100 gap-1.5 rounded-md border px-2.5 py-1.5 text-sm',
isDragging && 'shadow-lg',
)}
aria-label={label}
title={label}
{...attributes}
{...listeners}
>
{Icon ? <Icon className={isToolbar ? undefined : 'h-4 w-4 shrink-0'} /> : null}
{isToolbar ? null : <span className='whitespace-nowrap'>{label}</span>}
</button>
);
};
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 (
<SortableContext items={items} strategy={rectSortingStrategy}>
<div
ref={setNodeRef}
className={clsx(
'flex min-h-12 flex-wrap items-center gap-2 rounded-lg p-2',
isToolbar
? // A faithful, content-width preview of the real popup, start-aligned
// with the Available row below it.
'selection-popup bg-gray-600 text-white w-fit max-w-full'
: 'bg-base-200/60',
)}
>
{items.length === 0 ? (
<span
className={clsx('px-1 text-sm', isToolbar ? 'text-white/70' : 'text-base-content/50')}
>
{emptyHint}
</span>
) : (
items.map((type) => <React.Fragment key={type}>{renderChip(type)}</React.Fragment>)
)}
</div>
</SortableContext>
);
};
const AnnotationToolbarCustomizer: React.FC<AnnotationToolbarCustomizerProps> = ({
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<Record<ZoneId, AnnotationToolType[]>>(() => ({
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<Record<ZoneId, AnnotationToolType[]> | 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, AnnotationToolType[]>): 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<ZoneId, AnnotationToolType[]>;
});
};
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) => (
<ToolChip
type={type}
label={_(toolButtonOf(type)?.label ?? type)}
variant='toolbar'
onActivate={() => moveToAvailable(type)}
/>
);
const renderAvailableChip = (type: AnnotationToolType) => (
<ToolChip
type={type}
label={_(toolButtonOf(type)?.label ?? type)}
variant='available'
onActivate={() => moveToToolbar(type)}
/>
);
return (
<div className='w-full'>
<SubPageHeader
parentLabel={_('Behavior')}
currentLabel={_('Customize Toolbar')}
description={_(
'Drag tools between the rows to show or hide them and reorder the toolbar. You can also tap a tool to move it.',
)}
onBack={onBack}
rightSlot={
<div className='flex shrink-0 items-center gap-1'>
<button
type='button'
className='btn btn-ghost btn-xs'
onClick={addAll}
disabled={items.available.length === 0}
>
{_('Add all')}
</button>
<button
type='button'
className='btn btn-ghost btn-xs'
onClick={clearAll}
disabled={items.toolbar.length === 0}
>
{_('Clear all')}
</button>
</div>
}
/>
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{/* px-4 matches SubPageHeader so the zone labels align with the breadcrumb. */}
<div className='my-4 space-y-5 px-4'>
<div className='space-y-2'>
<div className='text-base-content/70 text-sm font-medium'>{_('In toolbar')}</div>
<Zone
id='toolbar'
items={items.toolbar}
emptyHint={_('No tools, drag one here')}
renderChip={renderToolbarChip}
/>
</div>
<div className='space-y-2'>
<div className='text-base-content/70 text-sm font-medium'>{_('Available')}</div>
<Zone
id='available'
items={items.available}
emptyHint={_('All tools are in the toolbar.')}
renderChip={renderAvailableChip}
/>
</div>
</div>
</DndContext>
</div>
);
};
export default AnnotationToolbarCustomizer;
@@ -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<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
@@ -44,6 +53,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const resetToDefaults = useResetViewSettings();
const pageTurnerResetRef = useRef<() => void>(() => {});
const canShare = canShareText(appService);
const handleReset = () => {
resetToDefaults({
@@ -75,6 +86,14 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ 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<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
saveViewSettings(envConfig, bookKey, 'annotationQuickAction', action, false, true);
};
if (showToolbarCustomizer) {
return (
<AnnotationToolbarCustomizer
bookKey={bookKey}
onBack={() => setShowToolbarCustomizer(false)}
/>
);
}
return (
<div className='my-4 w-full space-y-6'>
<BoxedList title={_('Scroll')} data-setting-id='settings.control.scrolledMode'>
@@ -356,6 +386,11 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
onChange={() => setCopyToNotebook(!copyToNotebook)}
data-setting-id='settings.control.copyToNotebook'
/>
<NavigationRow
title={_('Customize Toolbar')}
onClick={() => setShowToolbarCustomizer(true)}
data-setting-id='settings.control.customizeToolbar'
/>
</BoxedList>
<BoxedList title={_('Animation')} data-setting-id='settings.control.pagingAnimation'>
@@ -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,
};
+2 -1
View File
@@ -6,4 +6,5 @@ export type AnnotationToolType =
| 'dictionary'
| 'translate'
| 'tts'
| 'proofread';
| 'proofread'
| 'share';
+1
View File
@@ -327,6 +327,7 @@ export interface NoteExportConfig {
export interface AnnotatorConfig {
enableAnnotationQuickActions: boolean;
annotationQuickAction: AnnotationToolType | null;
annotationToolbarItems: AnnotationToolType[];
copyToNotebook: boolean;
noteExportConfig: NoteExportConfig;
}
@@ -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<AnnotationToolType>();
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;
};
+64
View File
@@ -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<void> => {
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