diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 04ef940e..2d85f23b 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1079,5 +1079,11 @@ "Books": "الكتب", "Groups": "المجموعات", "Back to TTS Location": "العودة إلى موقع القراءة الآلية", - "Metadata": "بيانات وصفية" + "Metadata": "بيانات وصفية", + "Image viewer": "عارض الصور", + "Previous Image": "الصورة السابقة", + "Next Image": "الصورة التالية", + "Zoomed": "مكبّر", + "Zoom level": "مستوى التكبير", + "Table viewer": "عارض الجداول" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 24f196df..2ef6145d 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1031,5 +1031,11 @@ "Books": "বই", "Groups": "দল", "Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান", - "Metadata": "মেটাডেটা" + "Metadata": "মেটাডেটা", + "Image viewer": "চিত্র প্রদর্শক", + "Previous Image": "পূর্ববর্তী চিত্র", + "Next Image": "পরবর্তী চিত্র", + "Zoomed": "জুম করা হয়েছে", + "Zoom level": "জুম স্তর", + "Table viewer": "টেবিল প্রদর্শক" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 64621cf5..d32ee4dc 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1019,5 +1019,11 @@ "Books": "དཔེ་ཆ།", "Groups": "ཚོགས་པ།", "Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།", - "Metadata": "གནད་སྨིན་གོ་དོན།" + "Metadata": "གནད་སྨིན་གོ་དོན།", + "Image viewer": "པར་རིས་ལྟ་བྱེད།", + "Previous Image": "སྔོན་མའི་པར་རིས།", + "Next Image": "རྗེས་མའི་པར་རིས།", + "Zoomed": "ཆེར་བསྐྱེད་ཟིན།", + "Zoom level": "ཆེར་བསྐྱེད་རིམ་པ།", + "Table viewer": "རེའུ་མིག་ལྟ་བྱེད།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index fe4d8233..3ddf458b 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1031,5 +1031,11 @@ "Books": "Bücher", "Groups": "Gruppen", "Back to TTS Location": "Zurück zur TTS-Position", - "Metadata": "Metadaten" + "Metadata": "Metadaten", + "Image viewer": "Bildbetrachter", + "Previous Image": "Vorheriges Bild", + "Next Image": "Nächstes Bild", + "Zoomed": "Gezoomt", + "Zoom level": "Zoomstufe", + "Table viewer": "Tabellenbetrachter" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 07d435f6..71845e10 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1031,5 +1031,11 @@ "Books": "Βιβλία", "Groups": "Ομάδες", "Back to TTS Location": "Επιστροφή στην τοποθεσία TTS", - "Metadata": "Μεταδεδομένα" + "Metadata": "Μεταδεδομένα", + "Image viewer": "Πρόγραμμα προβολής εικόνων", + "Previous Image": "Προηγούμενη εικόνα", + "Next Image": "Επόμενη εικόνα", + "Zoomed": "Ζουμαρισμένο", + "Zoom level": "Επίπεδο ζουμ", + "Table viewer": "Πρόγραμμα προβολής πινάκων" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 16a9c1f9..d80864b9 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1043,5 +1043,11 @@ "Books": "Libros", "Groups": "Grupos", "Back to TTS Location": "Volver a la ubicación de TTS", - "Metadata": "Metadatos" + "Metadata": "Metadatos", + "Image viewer": "Visor de imágenes", + "Previous Image": "Imagen anterior", + "Next Image": "Imagen siguiente", + "Zoomed": "Con zoom", + "Zoom level": "Nivel de zoom", + "Table viewer": "Visor de tablas" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 6ab14089..6f865096 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1031,5 +1031,11 @@ "Books": "کتاب‌ها", "Groups": "گروه‌ها", "Back to TTS Location": "بازگشت به مکان TTS", - "Metadata": "فراداده" + "Metadata": "فراداده", + "Image viewer": "نمایشگر تصویر", + "Previous Image": "تصویر قبلی", + "Next Image": "تصویر بعدی", + "Zoomed": "بزرگنمایی شده", + "Zoom level": "سطح بزرگنمایی", + "Table viewer": "نمایشگر جدول" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index f8a10958..4ec7ce30 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1043,5 +1043,11 @@ "Books": "Livres", "Groups": "Groupes", "Back to TTS Location": "Retour à l'emplacement TTS", - "Metadata": "Métadonnées" + "Metadata": "Métadonnées", + "Image viewer": "Visionneuse d'images", + "Previous Image": "Image précédente", + "Next Image": "Image suivante", + "Zoomed": "Zoomé", + "Zoom level": "Niveau de zoom", + "Table viewer": "Visionneuse de tableaux" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 37d997b5..0337a001 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1043,5 +1043,11 @@ "Reveal in Finder": "הצג ב-Finder", "Reveal in File Explorer": "הצג בסייר הקבצים", "Reveal in Folder": "הצג בתיקייה", - "Metadata": "מטא-נתונים" + "Metadata": "מטא-נתונים", + "Image viewer": "מציג תמונות", + "Previous Image": "תמונה הקודמת", + "Next Image": "תמונה הבאה", + "Zoomed": "בזום", + "Zoom level": "רמת זום", + "Table viewer": "מציג טבלאות" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 2c449aad..c2acad14 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1031,5 +1031,11 @@ "Books": "पुस्तकें", "Groups": "समूह", "Back to TTS Location": "टीटीएस स्थान पर वापस जाएं", - "Metadata": "मेटाडाटा" + "Metadata": "मेटाडाटा", + "Image viewer": "छवि दर्शक", + "Previous Image": "पिछली छवि", + "Next Image": "अगली छवि", + "Zoomed": "ज़ूम किया गया", + "Zoom level": "ज़ूम स्तर", + "Table viewer": "तालिका दर्शक" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 34bc857e..f4522bb8 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1019,5 +1019,11 @@ "Books": "Buku", "Groups": "Grup", "Back to TTS Location": "Kembali ke Lokasi TTS", - "Metadata": "Metadata" + "Metadata": "Metadata", + "Image viewer": "Penampil gambar", + "Previous Image": "Gambar Sebelumnya", + "Next Image": "Gambar Berikutnya", + "Zoomed": "Diperbesar", + "Zoom level": "Tingkat zoom", + "Table viewer": "Penampil tabel" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index f294e9eb..8d609cc6 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1043,5 +1043,11 @@ "Books": "Libri", "Groups": "Gruppi", "Back to TTS Location": "Torna alla posizione TTS", - "Metadata": "Metadati" + "Metadata": "Metadati", + "Image viewer": "Visualizzatore immagini", + "Previous Image": "Immagine precedente", + "Next Image": "Immagine successiva", + "Zoomed": "Zoomato", + "Zoom level": "Livello di zoom", + "Table viewer": "Visualizzatore tabelle" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 1c0de99e..01abffbe 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1019,5 +1019,11 @@ "Books": "書籍", "Groups": "グループ", "Back to TTS Location": "TTSの位置に戻る", - "Metadata": "メタデータ" + "Metadata": "メタデータ", + "Image viewer": "画像ビューア", + "Previous Image": "前の画像", + "Next Image": "次の画像", + "Zoomed": "ズーム済み", + "Zoom level": "ズームレベル", + "Table viewer": "テーブルビューア" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 8b412217..6f8af098 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1019,5 +1019,11 @@ "Books": "도서", "Groups": "그룹", "Back to TTS Location": "TTS 위치로 돌아가기", - "Metadata": "메타데이터" + "Metadata": "메타데이터", + "Image viewer": "이미지 뷰어", + "Previous Image": "이전 이미지", + "Next Image": "다음 이미지", + "Zoomed": "확대됨", + "Zoom level": "확대 수준", + "Table viewer": "테이블 뷰어" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 55d7d096..e6e4f437 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1019,5 +1019,11 @@ "Books": "Buku", "Groups": "Kumpulan", "Back to TTS Location": "Kembali ke Lokasi TTS", - "Metadata": "Metadata" + "Metadata": "Metadata", + "Image viewer": "Penyorot imej", + "Previous Image": "Imej Sebelumnya", + "Next Image": "Imej Seterusnya", + "Zoomed": "Dizum", + "Zoom level": "Tahap zum", + "Table viewer": "Penyorot jadual" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 506d3c48..510e3e9e 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1031,5 +1031,11 @@ "Books": "Boeken", "Groups": "Groepen", "Back to TTS Location": "Terug naar TTS-locatie", - "Metadata": "Metadata" + "Metadata": "Metadata", + "Image viewer": "Afbeeldingenviewer", + "Previous Image": "Vorige afbeelding", + "Next Image": "Volgende afbeelding", + "Zoomed": "Gezoomd", + "Zoom level": "Zoomniveau", + "Table viewer": "Tabelviewer" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 4da39a79..534281ef 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1055,5 +1055,11 @@ "Books": "Książki", "Groups": "Grupy", "Back to TTS Location": "Powrót do lokalizacji TTS", - "Metadata": "Metadane" + "Metadata": "Metadane", + "Image viewer": "Przeglądarka obrazów", + "Previous Image": "Poprzedni obraz", + "Next Image": "Następny obraz", + "Zoomed": "Powiększone", + "Zoom level": "Poziom powiększenia", + "Table viewer": "Przeglądarka tabel" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 8eb5e006..94bded06 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1043,5 +1043,11 @@ "Books": "Livros", "Groups": "Grupos", "Back to TTS Location": "Voltar para a localização do TTS", - "Metadata": "Metadados" + "Metadata": "Metadados", + "Image viewer": "Visualizador de imagens", + "Previous Image": "Imagem anterior", + "Next Image": "Próxima imagem", + "Zoomed": "Com zoom", + "Zoom level": "Nível de zoom", + "Table viewer": "Visualizador de tabelas" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 5dd8a855..ec36dd1a 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1055,5 +1055,11 @@ "Books": "Книги", "Groups": "Группы", "Back to TTS Location": "Вернуться к местоположению TTS", - "Metadata": "Метаданные" + "Metadata": "Метаданные", + "Image viewer": "Просмотрщик изображений", + "Previous Image": "Предыдущее изображение", + "Next Image": "Следующее изображение", + "Zoomed": "Увеличено", + "Zoom level": "Уровень масштаба", + "Table viewer": "Просмотрщик таблиц" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 04dd803e..6f409992 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1031,5 +1031,11 @@ "Books": "පොත්", "Groups": "කණ්ඩායම්", "Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න", - "Metadata": "මෙටාඩේටා" + "Metadata": "මෙටාඩේටා", + "Image viewer": "රූප නරඹන්නා", + "Previous Image": "පූර්ව රූපය", + "Next Image": "මීළඟ රූපය", + "Zoomed": "විශාලනය කරන ලදී", + "Zoom level": "විශාලන මට්ටම", + "Table viewer": "වගු නරඹන්නා" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 4af0acfb..57523d84 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1031,5 +1031,11 @@ "Books": "Böcker", "Groups": "Grupper", "Back to TTS Location": "Tillbaka till TTS-plats", - "Metadata": "Metadata" + "Metadata": "Metadata", + "Image viewer": "Bildvisare", + "Previous Image": "Föregående bild", + "Next Image": "Nästa bild", + "Zoomed": "Zoomad", + "Zoom level": "Zoomnivå", + "Table viewer": "Tabellvisare" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 7b7ae459..f43e0c8c 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1031,5 +1031,11 @@ "Books": "புத்தகங்கள்", "Groups": "குழுக்கள்", "Back to TTS Location": "TTS இடத்திற்குத் திரும்பு", - "Metadata": "மெட்டாடேட்டா" + "Metadata": "மெட்டாடேட்டா", + "Image viewer": "படக் காட்சிப்பவர்", + "Previous Image": "முந்தைய படம்", + "Next Image": "அடுத்த படம்", + "Zoomed": "பெரிதாக்கப்பட்டது", + "Zoom level": "பெரிதாக்கும் நிலை", + "Table viewer": "அட்டவணை காட்சிப்பவர்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index e1114ba3..d6c61208 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1019,5 +1019,11 @@ "Books": "หนังสือ", "Groups": "กลุ่ม", "Back to TTS Location": "กลับไปยังตำแหน่ง TTS", - "Metadata": "เมทาดาตา" + "Metadata": "เมทาดาตา", + "Image viewer": "ตัวดูรูปภาพ", + "Previous Image": "รูปภาพก่อนหน้า", + "Next Image": "รูปภาพถัดไป", + "Zoomed": "ซูมแล้ว", + "Zoom level": "ระดับการซูม", + "Table viewer": "ตัวดูตาราง" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index adad59c2..5784eb5a 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1031,5 +1031,11 @@ "Books": "Kitaplar", "Groups": "Gruplar", "Back to TTS Location": "TTS Konumuna Geri Dön", - "Metadata": "Meta veriler" + "Metadata": "Meta veriler", + "Image viewer": "Resim görüntüleyici", + "Previous Image": "Önceki Resim", + "Next Image": "Sonraki Resim", + "Zoomed": "Yakınlaştırıldı", + "Zoom level": "Yakınlaştırma düzeyi", + "Table viewer": "Tablo görüntüleyici" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 4d320ef1..eafb9200 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1055,5 +1055,11 @@ "Books": "Книги", "Groups": "Групи", "Back to TTS Location": "Повернутися до розташування TTS", - "Metadata": "Метадані" + "Metadata": "Метадані", + "Image viewer": "Переглядач зображень", + "Previous Image": "Попереднє зображення", + "Next Image": "Наступне зображення", + "Zoomed": "Збільшено", + "Zoom level": "Рівень масштабу", + "Table viewer": "Переглядач таблиць" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 74c4fe45..824f9d1e 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1019,5 +1019,11 @@ "Books": "Sách", "Groups": "Nhóm", "Back to TTS Location": "Quay lại vị trí TTS", - "Metadata": "Siêu dữ liệu" + "Metadata": "Siêu dữ liệu", + "Image viewer": "Trình xem ảnh", + "Previous Image": "Ảnh trước", + "Next Image": "Ảnh sau", + "Zoomed": "Đã thu phóng", + "Zoom level": "Mức thu phóng", + "Table viewer": "Trình xem bảng" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 9a6d5467..6d2e92bd 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1019,5 +1019,11 @@ "Books": "书籍", "Groups": "分组", "Back to TTS Location": "回到朗读位置", - "Metadata": "元数据" + "Metadata": "元数据", + "Image viewer": "图片查看器", + "Previous Image": "上一张图片", + "Next Image": "下一张图片", + "Zoomed": "已缩放", + "Zoom level": "缩放级别", + "Table viewer": "表格查看器" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 860c2ff8..45b5f23c 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1019,5 +1019,11 @@ "Books": "書籍", "Groups": "分組", "Back to TTS Location": "回到朗讀位置", - "Metadata": "元數據" + "Metadata": "元數據", + "Image viewer": "圖片檢視器", + "Previous Image": "上一張圖片", + "Next Image": "下一張圖片", + "Zoomed": "已縮放", + "Zoom level": "縮放級別", + "Table viewer": "表格檢視器" } diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index d9afb5fc..80c35d06 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { BookDoc, getDirection } from '@/libs/document'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { convertBlobUrlToDataUrl, BookDoc, getDirection } from '@/libs/document'; import { BookConfig } from '@/types/book'; import { FoliateView, wrappedFoliateView } from '@/types/view'; import { Insets } from '@/types/misc'; @@ -10,7 +10,7 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useCustomFontStore } from '@/store/customFontStore'; import { useParallelViewStore } from '@/store/parallelViewStore'; -import { useMouseEvent, useTouchEvent } from '../hooks/useIframeEvents'; +import { useMouseEvent, useTouchEvent, useLongPressEvent } from '../hooks/useIframeEvents'; import { usePagination } from '../hooks/usePagination'; import { useFoliateEvents } from '../hooks/useFoliateEvents'; import { useProgressSync } from '../hooks/useProgressSync'; @@ -44,6 +44,7 @@ import { handleTouchStart, handleTouchMove, handleTouchEnd, + addLongPressListeners, } from '../utils/iframeEventHandlers'; import { getMaxInlineSize } from '@/utils/config'; import { getDirFromUILanguage } from '@/utils/rtl'; @@ -59,9 +60,11 @@ import { getViewInsets } from '@/utils/insets'; import { handleA11yNavigation } from '@/utils/a11y'; import { isCJKLang } from '@/utils/lang'; import { getLocale } from '@/utils/misc'; +import { ParagraphControl } from './paragraph'; import Spinner from '@/components/Spinner'; import KOSyncConflictResolver from './KOSyncResolver'; -import { ParagraphControl } from './paragraph'; +import ImageViewer from './ImageViewer'; +import TableViewer from './TableViewer'; declare global { interface Window { @@ -247,6 +250,7 @@ const FoliateViewer: React.FC<{ detail.doc.addEventListener('touchstart', handleTouchStart.bind(null, bookKey)); detail.doc.addEventListener('touchmove', handleTouchMove.bind(null, bookKey)); detail.doc.addEventListener('touchend', handleTouchEnd.bind(null, bookKey)); + addLongPressListeners(bookKey, detail.doc); } } }; @@ -288,6 +292,88 @@ const FoliateViewer: React.FC<{ const mouseHandlers = useMouseEvent(bookKey, handlePageFlip, handleContinuousScroll); const touchHandlers = useTouchEvent(bookKey, handlePageFlip, handleContinuousScroll); + const [selectedImage, setSelectedImage] = useState(null); + const [selectedTableHtml, setSelectedTableHtml] = useState(null); + const [imageList, setImageList] = useState<{ src: string; cfi: string | null }[]>([]); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + + const handleImagePress = useCallback(async (src: string) => { + try { + // Get all images from the current document + const docs = viewRef.current?.renderer.getContents(); + const allImages: { src: string; cfi: string | null }[] = []; + + docs?.forEach(({ doc, index }) => { + const images = doc.querySelectorAll('img'); + images.forEach((img) => { + if (img.src && index !== undefined && img.parentNode) { + const range = doc.createRange(); + range.selectNodeContents(img); + const cfi = viewRef.current?.getCFI(index, range) || null; + allImages.push({ src: img.src, cfi }); + } + }); + }); + + // Find the index of the pressed image + const index = allImages.findIndex((img) => img.src === src); + + setImageList(allImages); + setCurrentImageIndex(index >= 0 ? index : 0); + + const dataUrl = await convertBlobUrlToDataUrl(src); + setSelectedImage(dataUrl); + } catch (error) { + console.error('Failed to load image:', error); + } + }, []); + + const handleTablePress = useCallback((html: string) => { + setSelectedTableHtml(html); + }, []); + + const handlePreviousImage = useCallback(async () => { + if (currentImageIndex > 0 && imageList.length > 0) { + const newIndex = currentImageIndex - 1; + setCurrentImageIndex(newIndex); + try { + const { src, cfi } = imageList[newIndex]!; + const dataUrl = await convertBlobUrlToDataUrl(src); + setSelectedImage(dataUrl); + if (cfi && viewRef.current) { + viewRef.current?.goTo(cfi); + } + } catch (error) { + console.error('Failed to load previous image:', error); + } + } + }, [currentImageIndex, imageList]); + + const handleNextImage = useCallback(async () => { + if (currentImageIndex < imageList.length - 1 && imageList.length > 0) { + const newIndex = currentImageIndex + 1; + setCurrentImageIndex(newIndex); + try { + const { src, cfi } = imageList[newIndex]!; + const dataUrl = await convertBlobUrlToDataUrl(src); + setSelectedImage(dataUrl); + if (cfi && viewRef.current) { + viewRef.current?.goTo(cfi); + } + } catch (error) { + console.error('Failed to load next image:', error); + } + } + }, [currentImageIndex, imageList]); + + const handleCloseImage = useCallback(() => { + setSelectedImage(null); + setImageList([]); + setCurrentImageIndex(0); + }, []); + + useLongPressEvent(bookKey, handleImagePress, handleTablePress); + useFoliateEvents(viewRef.current, { onLoad: docLoadHandler, onRelocate: progressRelocateHandler, @@ -501,6 +587,21 @@ const FoliateViewer: React.FC<{ return ( <> + {selectedImage && ( + 0 ? handlePreviousImage : undefined} + onNext={currentImageIndex < imageList.length - 1 ? handleNextImage : undefined} + /> + )} + {selectedTableHtml && ( + setSelectedTableHtml(null)} + /> + )}
void; + onPrevious?: () => void; + onNext?: () => void; +} + +const MIN_SCALE = 0.5; +const MAX_SCALE = 8; +const ZOOM_SPEED = 0.1; +const MOBILE_ZOOM_SPEED = 0.001; +const ZOOM_BIAS = 1.05; + +const ImageViewer: React.FC = ({ src, onClose, onPrevious, onNext }) => { + const _ = useTranslation(); + const [scale, setScale] = useState(1); + const [zoomSpeed, setZoomSpeed] = useState(0.1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [showZoomLabel, setShowZoomLabel] = useState(true); + const lastTouchDistance = useRef(0); + const dragStart = useRef({ x: 0, y: 0 }); + const wasDragging = useRef(false); + const containerRef = useRef(null); + const imageRef = useRef(null); + const zoomLabelTimeoutRef = useRef | null>(null); + + const hideZoomLabelAfterDelay = () => { + if (zoomLabelTimeoutRef.current) { + clearTimeout(zoomLabelTimeoutRef.current); + } + setShowZoomLabel(true); + zoomLabelTimeoutRef.current = setTimeout(() => { + setShowZoomLabel(false); + }, 2000); + }; + + const handleZoomIn = () => { + const newScale = Math.min(scale + ZOOM_SPEED, MAX_SCALE); + setScale(newScale); + setZoomSpeed(ZOOM_SPEED * ZOOM_BIAS * newScale); + hideZoomLabelAfterDelay(); + }; + + const handleZoomOut = () => { + const newScale = Math.max(scale - ZOOM_SPEED, MIN_SCALE); + if (newScale <= 1) { + setPosition({ x: 0, y: 0 }); + setScale(newScale); + setZoomSpeed(ZOOM_SPEED); + } else { + setScale(newScale); + setZoomSpeed(ZOOM_SPEED * ZOOM_BIAS * newScale); + } + hideZoomLabelAfterDelay(); + }; + + const handleResetZoom = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + setZoomSpeed(ZOOM_SPEED); + hideZoomLabelAfterDelay(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape') { + onClose(); + return; + } + + // Arrow key navigation + if (e.key === 'ArrowLeft' && onPrevious) { + e.preventDefault(); + handlePreviousImage(); + return; + } + + if (e.key === 'ArrowRight' && onNext) { + e.preventDefault(); + handleNextImage(); + return; + } + + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + if (isCtrlOrCmd) { + e.preventDefault(); + + if (e.key === '=' || e.key === '+') { + handleZoomIn(); + } else if (e.key === '-' || e.key === '_') { + handleZoomOut(); + } else if (e.key === '0') { + handleResetZoom(); + } + } + }; + + const handlePreviousImage = () => { + if (onPrevious) { + onPrevious(); + hideZoomLabelAfterDelay(); + } + }; + + const handleNextImage = () => { + if (onNext) { + onNext(); + hideZoomLabelAfterDelay(); + } + }; + + const getZoomedOffset = ( + anchorX: number, + anchorY: number, + currentScale: number, + nextScale: number, + currentPos: { x: number; y: number }, + ) => { + const scaleChange = nextScale / currentScale; + return { + x: anchorX - (anchorX - currentPos.x) * scaleChange, + y: anchorY - (anchorY - currentPos.y) * scaleChange, + }; + }; + + // Grab Focus of modal and set up initial zoom label timeout + useEffect(() => { + containerRef.current?.focus(); + setTimeout(() => { + hideZoomLabelAfterDelay(); + }, 0); + + return () => { + if (zoomLabelTimeoutRef.current) { + clearTimeout(zoomLabelTimeoutRef.current); + } + }; + }, []); + + const handleWheel = (e: React.WheelEvent) => { + // Only zoom when Ctrl/Cmd is pressed for consistency with browser behavior + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + if (!isCtrlOrCmd) { + // Allow default behavior (no zoom without modifier) + return; + } + + e.preventDefault(); + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const delta = e.deltaY > 0 ? -zoomSpeed : zoomSpeed; + const newScale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE); + const newZoom = ZOOM_SPEED * ZOOM_BIAS * newScale; + + if (newScale <= 1) { + setPosition({ x: 0, y: 0 }); + setScale(newScale); + setZoomSpeed(ZOOM_SPEED); + hideZoomLabelAfterDelay(); + return; + } + + // Mouse position relative to the container element + const mouseX = e.clientX - rect.left - rect.width / 2; + const mouseY = e.clientY - rect.top - rect.height / 2; + + setPosition((prevPos) => { + return getZoomedOffset(mouseX, mouseY, scale, newScale, prevPos); + }); + + setScale(newScale); + setZoomSpeed(newZoom); + hideZoomLabelAfterDelay(); + }; + + const handleImageMouseDown = (e: React.MouseEvent) => { + if (isDragging || scale <= 1) return; + e.stopPropagation(); + e.preventDefault(); + + setIsDragging(true); + wasDragging.current = false; + dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y }; + }; + + const handleImageMouseMove = (e: React.MouseEvent) => { + if (!isDragging || scale <= 1) return; + e.preventDefault(); + + wasDragging.current = true; + const newX = e.clientX - dragStart.current.x; + const newY = e.clientY - dragStart.current.y; + + setPosition({ x: newX, y: newY }); + }; + + const handleImageMouseUp = (e: React.MouseEvent) => { + if (isDragging) { + e.stopPropagation(); + } + setIsDragging(false); + }; + + const onTouchStart = (e: React.TouchEvent) => { + const touches = e.touches; + + if (touches.length === 1 && scale > 1) { + // Pan Start + setIsDragging(true); + wasDragging.current = false; + const touch = touches[0]; + if (!touch) return; + dragStart.current = { + x: touch.clientX - position.x, + y: touch.clientY - position.y, + }; + } else if (touches.length === 2) { + // Pinch Start + setIsDragging(true); + wasDragging.current = false; + const touch1 = touches[0]; + const touch2 = touches[1]; + if (!touch1 || !touch2) return; + lastTouchDistance.current = Math.hypot( + touch1.clientX - touch2.clientX, + touch1.clientY - touch2.clientY, + ); + } + }; + + const onTouchMove = (e: React.TouchEvent) => { + const touches = e.touches; + + if (touches.length === 1 && scale > 1 && isDragging) { + // Pan + wasDragging.current = true; + const touch = touches[0]; + if (!touch) return; + + requestAnimationFrame(() => { + const newX = touch.clientX - dragStart.current.x; + const newY = touch.clientY - dragStart.current.y; + + setPosition({ x: newX, y: newY }); + }); + } else if (touches.length === 2) { + // Pinch + wasDragging.current = true; + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const touch1 = touches[0]; + const touch2 = touches[1]; + if (!touch1 || !touch2) return; + const currentDistance = Math.hypot( + touch1.clientX - touch2.clientX, + touch1.clientY - touch2.clientY, + ); + const distanceChange = currentDistance / lastTouchDistance.current; + + requestAnimationFrame(() => { + const newScale = Math.min(Math.max(scale * distanceChange, MIN_SCALE), MAX_SCALE); + const newZoom = MOBILE_ZOOM_SPEED * ZOOM_BIAS * distanceChange; + + if (newScale <= 1) { + setPosition({ x: 0, y: 0 }); + setScale(newScale); + setZoomSpeed(ZOOM_SPEED); + return; + } + + // Touch position relative to the container element + const touchX = (touch1.clientX + touch2.clientX) / 2 - rect.left - rect.width / 2; + const touchY = (touch1.clientY + touch2.clientY) / 2 - rect.top - rect.height / 2; + + setPosition((prevPos) => { + return getZoomedOffset(touchX, touchY, scale, newScale, prevPos); + }); + + setScale(newScale); + setZoomSpeed(newZoom); + + lastTouchDistance.current = currentDistance; + }); + } + }; + + const onTouchEnd = (e: React.TouchEvent) => { + const touches = e.touches; + if (touches.length === 1) { + const touch = touches[0]; + if (!touch) return; + dragStart.current = { + x: touch.clientX - position.x, + y: touch.clientY - position.y, + }; + } + if (touches.length === 0) { + setIsDragging(false); + } + }; + + const handleReset = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + setZoomSpeed(ZOOM_SPEED); + hideZoomLabelAfterDelay(); + }; + + const onDoubleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + if (scale === 1) { + const mouseX = e.clientX - rect.left - rect.width / 2; + const mouseY = e.clientY - rect.top - rect.height / 2; + const newScale = 2; + + setPosition((prevPos) => { + return getZoomedOffset(mouseX, mouseY, scale, newScale, prevPos); + }); + setScale(newScale); + hideZoomLabelAfterDelay(); + } else { + handleReset(); + } + }; + + const handleContainerClick = () => { + if (wasDragging.current) { + wasDragging.current = false; + return; + } + onClose(); + }; + + const handleImageClick = (e: React.MouseEvent) => { + // Stop propagation to prevent closing when clicking on the image + e.stopPropagation(); + + // Don't toggle label if user was dragging + if (wasDragging.current) { + wasDragging.current = false; + return; + } + + setShowZoomLabel((prev) => !prev); + }; + + const cursorStyle = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'; + + if (!src) return null; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + onClose(); + } + }} + /> + + + {onPrevious && showZoomLabel && ( + + )} + + {onNext && showZoomLabel && ( + + )} + +
+ {_('Zoomed')} +
+ + {showZoomLabel && ( +
+ {Math.round((scale * 100) / 5) * 5}% +
+ )} +
+ ); +}; + +export default ImageViewer; diff --git a/apps/readest-app/src/app/reader/components/TableViewer.tsx b/apps/readest-app/src/app/reader/components/TableViewer.tsx new file mode 100644 index 00000000..4c97807e --- /dev/null +++ b/apps/readest-app/src/app/reader/components/TableViewer.tsx @@ -0,0 +1,251 @@ +import clsx from 'clsx'; +import React, { useState, useRef, useEffect } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import ZoomControls from './ZoomControls'; + +interface TableViewerProps { + html: string | null; + isDarkMode: boolean; + onClose: () => void; +} + +const MIN_SCALE = 0.5; +const MAX_SCALE = 4; +const ZOOM_SPEED = 0.1; + +const TableViewer: React.FC = ({ html, isDarkMode, onClose }) => { + const _ = useTranslation(); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [showZoomLabel, setShowZoomLabel] = useState(true); + const dragStart = useRef({ x: 0, y: 0 }); + const wasDragging = useRef(false); + const containerRef = useRef(null); + const contentRef = useRef(null); + const zoomLabelTimeoutRef = useRef | null>(null); + + const hideZoomLabelAfterDelay = () => { + if (zoomLabelTimeoutRef.current) { + clearTimeout(zoomLabelTimeoutRef.current); + } + setShowZoomLabel(true); + zoomLabelTimeoutRef.current = setTimeout(() => { + setShowZoomLabel(false); + }, 2000); + }; + + const handleZoomIn = () => { + const newScale = Math.min(scale + ZOOM_SPEED, MAX_SCALE); + setScale(newScale); + hideZoomLabelAfterDelay(); + }; + + const handleZoomOut = () => { + const newScale = Math.max(scale - ZOOM_SPEED, MIN_SCALE); + if (newScale <= 1) { + setPosition({ x: 0, y: 0 }); + setScale(newScale); + } else { + setScale(newScale); + } + hideZoomLabelAfterDelay(); + }; + + const handleResetZoom = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + hideZoomLabelAfterDelay(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape') { + onClose(); + return; + } + + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + + if (isCtrlOrCmd) { + e.preventDefault(); + + if (e.key === '=' || e.key === '+') { + handleZoomIn(); + } else if (e.key === '-' || e.key === '_') { + handleZoomOut(); + } else if (e.key === '0') { + handleResetZoom(); + } + } + }; + + useEffect(() => { + containerRef.current?.focus(); + setTimeout(() => { + hideZoomLabelAfterDelay(); + }, 0); + + return () => { + if (zoomLabelTimeoutRef.current) { + clearTimeout(zoomLabelTimeoutRef.current); + } + }; + }, []); + + const handleWheel = (e: React.WheelEvent) => { + const isCtrlOrCmd = e.ctrlKey || e.metaKey; + if (!isCtrlOrCmd) { + return; + } + + e.preventDefault(); + + const delta = e.deltaY > 0 ? -ZOOM_SPEED : ZOOM_SPEED; + const newScale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE); + + if (newScale <= 1) { + setPosition({ x: 0, y: 0 }); + setScale(newScale); + hideZoomLabelAfterDelay(); + return; + } + + setScale(newScale); + hideZoomLabelAfterDelay(); + }; + + const handleContentMouseDown = (e: React.MouseEvent) => { + if (isDragging || scale <= 1) return; + e.stopPropagation(); + e.preventDefault(); + + setIsDragging(true); + wasDragging.current = false; + dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y }; + }; + + const handleContentMouseMove = (e: React.MouseEvent) => { + if (!isDragging || scale <= 1) return; + e.preventDefault(); + + wasDragging.current = true; + const newX = e.clientX - dragStart.current.x; + const newY = e.clientY - dragStart.current.y; + + setPosition({ x: newX, y: newY }); + }; + + const handleContentMouseUp = (e: React.MouseEvent) => { + if (isDragging) { + e.stopPropagation(); + } + setIsDragging(false); + }; + + const handleContainerClick = () => { + if (wasDragging.current) { + wasDragging.current = false; + return; + } + onClose(); + }; + + const handleContentClick = (e: React.MouseEvent) => { + e.stopPropagation(); + + if (wasDragging.current) { + wasDragging.current = false; + return; + } + + setShowZoomLabel((prev) => !prev); + }; + + const cursorStyle = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'; + + if (!html) return null; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + onClose(); + } + }} + /> + + +
+
+
+ + {showZoomLabel && ( +
+ {Math.round((scale * 100) / 5) * 5}% +
+ )} + + +
+ ); +}; + +export default TableViewer; diff --git a/apps/readest-app/src/app/reader/components/ZoomControls.tsx b/apps/readest-app/src/app/reader/components/ZoomControls.tsx new file mode 100644 index 00000000..6182610b --- /dev/null +++ b/apps/readest-app/src/app/reader/components/ZoomControls.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { IoClose, IoExpand, IoAdd, IoRemove } from 'react-icons/io5'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface ZoomControlsProps { + onClose: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onReset: () => void; +} + +const ZoomControls: React.FC = ({ onClose, onZoomIn, onZoomOut, onReset }) => { + const _ = useTranslation(); + return ( +
+ + + + + + + +
+ ); +}; + +export default ZoomControls; diff --git a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts index 68196028..37b76579 100644 --- a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts @@ -55,6 +55,30 @@ export const useMouseEvent = ( }; }; +export const useLongPressEvent = ( + bookKey: string, + handleImagePress: (src: string) => void, + handleTablePress: (html: string) => void, +) => { + const handleLongPress = (msg: MessageEvent) => { + if (msg.data && msg.data.bookKey === bookKey && msg.data.type === 'iframe-long-press') { + if (msg.data.elementType === 'image') { + handleImagePress(msg.data.src); + } else if (msg.data.elementType === 'table') { + handleTablePress(msg.data.html); + } + } + }; + + useEffect(() => { + window.addEventListener('message', handleLongPress); + return () => { + window.removeEventListener('message', handleLongPress); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bookKey]); +}; + interface IframeTouch { clientX: number; clientY: number; diff --git a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts index 916aa8a8..5d496785 100644 --- a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -267,3 +267,180 @@ export const handleTouchMove = (bookKey: string, event: TouchEvent) => { export const handleTouchEnd = (bookKey: string, event: TouchEvent) => { handleTouchEv(bookKey, event, 'iframe-touchend'); }; + +export const addLongPressListeners = (bookKey: string, doc: Document) => { + const longPressDuration = 500; + const moveThreshold = 10; // pixels - movement threshold to detect dragging/selection + const pressTimers = new Map>(); + const pressStartPositions = new Map(); + + const handleLongPress = (event: Event, target: HTMLElement) => { + event.preventDefault?.(); + + // Check if there's an active text selection - if so, don't trigger long-press + const selection = doc.getSelection(); + if (selection && selection.toString().length > 0) { + return; + } + + if (target.tagName === 'IMG') { + const imgTarget = target as HTMLImageElement; + window.postMessage( + { + type: 'iframe-long-press', + bookKey, + elementType: 'image', + src: imgTarget.src, + }, + '*', + ); + } else if (target.tagName === 'TABLE' || target.closest('table')) { + const tableTarget = ( + target.tagName === 'TABLE' ? target : target.closest('table') + ) as HTMLTableElement; + window.postMessage( + { + type: 'iframe-long-press', + bookKey, + elementType: 'table', + html: tableTarget.outerHTML, + }, + '*', + ); + } + }; + + const startPress = (event: Event) => { + const target = event.target as HTMLElement; + const isImage = target.tagName === 'IMG'; + const isTableOrInTable = target.tagName === 'TABLE' || target.closest('table'); + + if (!isImage && !isTableOrInTable) return; + + const elementToTrack = isImage + ? target + : ((target.tagName === 'TABLE' ? target : target.closest('table')) as HTMLElement); + + // Store initial position for movement detection + if ('clientX' in event && 'clientY' in event) { + const mouseEvent = event as MouseEvent; + pressStartPositions.set(elementToTrack, { x: mouseEvent.clientX, y: mouseEvent.clientY }); + } else if ('touches' in event) { + const touchEvent = event as TouchEvent; + const touch = touchEvent.touches[0]; + if (touch) { + pressStartPositions.set(elementToTrack, { x: touch.clientX, y: touch.clientY }); + } + } + + clearTimeout(pressTimers.get(elementToTrack)); + const timer = setTimeout(() => handleLongPress(event, elementToTrack), longPressDuration); + pressTimers.set(elementToTrack, timer); + }; + + const handleMove = (event: Event) => { + const target = event.target as HTMLElement; + const isImage = target.tagName === 'IMG'; + const isTableOrInTable = target.tagName === 'TABLE' || target.closest('table'); + + if (!isImage && !isTableOrInTable) return; + + const elementToTrack = isImage + ? target + : ((target.tagName === 'TABLE' ? target : target.closest('table')) as HTMLElement); + + // Check if mouse/touch moved beyond threshold - if so, user is probably selecting text or dragging + const startPos = pressStartPositions.get(elementToTrack); + if (startPos) { + let currentX = 0; + let currentY = 0; + + if ('clientX' in event && 'clientY' in event) { + const mouseEvent = event as MouseEvent; + currentX = mouseEvent.clientX; + currentY = mouseEvent.clientY; + } else if ('touches' in event) { + const touchEvent = event as TouchEvent; + const touch = touchEvent.touches[0]; + if (touch) { + currentX = touch.clientX; + currentY = touch.clientY; + } + } + + const distance = Math.sqrt( + Math.pow(currentX - startPos.x, 2) + Math.pow(currentY - startPos.y, 2), + ); + + // If moved beyond threshold, cancel the long-press + if (distance > moveThreshold) { + clearTimeout(pressTimers.get(elementToTrack)); + pressTimers.delete(elementToTrack); + pressStartPositions.delete(elementToTrack); + } + } + }; + + const cancelPress = (event: Event) => { + const target = event.target as HTMLElement; + const isImage = target.tagName === 'IMG'; + const isTableOrInTable = target.tagName === 'TABLE' || target.closest('table'); + + if (!isImage && !isTableOrInTable) return; + + const elementToTrack = isImage + ? target + : ((target.tagName === 'TABLE' ? target : target.closest('table')) as HTMLElement); + + clearTimeout(pressTimers.get(elementToTrack)); + pressTimers.delete(elementToTrack); + pressStartPositions.delete(elementToTrack); + }; + + const processElements = () => { + const images = doc.querySelectorAll('img'); + const tables = doc.querySelectorAll('table'); + + images.forEach((img) => { + if (!img.hasAttribute('data-long-press-added')) { + img.setAttribute('data-long-press-added', 'true'); + img.addEventListener('mousedown', startPress); + img.addEventListener('mousemove', handleMove); + img.addEventListener('mouseup', cancelPress); + img.addEventListener('mouseleave', cancelPress); + img.addEventListener('touchstart', startPress, { passive: true }); + img.addEventListener('touchmove', handleMove, { passive: true }); + img.addEventListener('touchend', cancelPress); + } + }); + + tables.forEach((table) => { + if (!table.hasAttribute('data-long-press-added')) { + table.setAttribute('data-long-press-added', 'true'); + table.addEventListener('mousedown', startPress); + table.addEventListener('mousemove', handleMove); + table.addEventListener('mouseup', cancelPress); + table.addEventListener('mouseleave', cancelPress); + table.addEventListener('touchstart', startPress, { passive: true }); + table.addEventListener('touchmove', handleMove, { passive: true }); + table.addEventListener('touchend', cancelPress); + } + }); + }; + + processElements(); + + const observer = new MutationObserver((mutations) => { + const hasNewElements = mutations.some((m) => m.type === 'childList' && m.addedNodes.length > 0); + if (hasNewElements) { + processElements(); + } + }); + + observer.observe(doc.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + pressTimers.forEach((timer) => clearTimeout(timer)); + }; +}; diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index 59ab8606..e321cd9e 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -283,3 +283,24 @@ export const getMimeTypeFromFileExt = (ext: string): string => { } return 'application/octet-stream'; }; + +export const convertBlobUrlToDataUrl = async (blobUrl: string): Promise => { + try { + const response = await fetch(blobUrl); + if (!response.ok) { + throw new Error( + `Failed to fetch blob from "${blobUrl}": ${response.status} ${response.statusText}`, + ); + } + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } catch (error) { + console.error('Failed to convert blob to data URL:', error); + throw error; + } +};