feat(ui): image viewing mode support (#3328)
* feat(ui): image viewing mode support * Revert foliate-js submodule pointer to 72fda6a (CI-compatible) * Fix long-press image viewing in foliateViewer instead of foliate-js and other refactors * feat: add images navigation buttons and table viewer --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
@@ -1079,5 +1079,11 @@
|
||||
"Books": "الكتب",
|
||||
"Groups": "المجموعات",
|
||||
"Back to TTS Location": "العودة إلى موقع القراءة الآلية",
|
||||
"Metadata": "بيانات وصفية"
|
||||
"Metadata": "بيانات وصفية",
|
||||
"Image viewer": "عارض الصور",
|
||||
"Previous Image": "الصورة السابقة",
|
||||
"Next Image": "الصورة التالية",
|
||||
"Zoomed": "مكبّر",
|
||||
"Zoom level": "مستوى التكبير",
|
||||
"Table viewer": "عارض الجداول"
|
||||
}
|
||||
|
||||
@@ -1031,5 +1031,11 @@
|
||||
"Books": "বই",
|
||||
"Groups": "দল",
|
||||
"Back to TTS Location": "টিটিএস অবস্থানে ফিরে যান",
|
||||
"Metadata": "মেটাডেটা"
|
||||
"Metadata": "মেটাডেটা",
|
||||
"Image viewer": "চিত্র প্রদর্শক",
|
||||
"Previous Image": "পূর্ববর্তী চিত্র",
|
||||
"Next Image": "পরবর্তী চিত্র",
|
||||
"Zoomed": "জুম করা হয়েছে",
|
||||
"Zoom level": "জুম স্তর",
|
||||
"Table viewer": "টেবিল প্রদর্শক"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,11 @@
|
||||
"Books": "དཔེ་ཆ།",
|
||||
"Groups": "ཚོགས་པ།",
|
||||
"Back to TTS Location": "TTS གནས་སར་ཕྱིར་ལོག་པ།",
|
||||
"Metadata": "གནད་སྨིན་གོ་དོན།"
|
||||
"Metadata": "གནད་སྨིན་གོ་དོན།",
|
||||
"Image viewer": "པར་རིས་ལྟ་བྱེད།",
|
||||
"Previous Image": "སྔོན་མའི་པར་རིས།",
|
||||
"Next Image": "རྗེས་མའི་པར་རིས།",
|
||||
"Zoomed": "ཆེར་བསྐྱེད་ཟིན།",
|
||||
"Zoom level": "ཆེར་བསྐྱེད་རིམ་པ།",
|
||||
"Table viewer": "རེའུ་མིག་ལྟ་བྱེད།"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1031,5 +1031,11 @@
|
||||
"Books": "Βιβλία",
|
||||
"Groups": "Ομάδες",
|
||||
"Back to TTS Location": "Επιστροφή στην τοποθεσία TTS",
|
||||
"Metadata": "Μεταδεδομένα"
|
||||
"Metadata": "Μεταδεδομένα",
|
||||
"Image viewer": "Πρόγραμμα προβολής εικόνων",
|
||||
"Previous Image": "Προηγούμενη εικόνα",
|
||||
"Next Image": "Επόμενη εικόνα",
|
||||
"Zoomed": "Ζουμαρισμένο",
|
||||
"Zoom level": "Επίπεδο ζουμ",
|
||||
"Table viewer": "Πρόγραμμα προβολής πινάκων"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1031,5 +1031,11 @@
|
||||
"Books": "کتابها",
|
||||
"Groups": "گروهها",
|
||||
"Back to TTS Location": "بازگشت به مکان TTS",
|
||||
"Metadata": "فراداده"
|
||||
"Metadata": "فراداده",
|
||||
"Image viewer": "نمایشگر تصویر",
|
||||
"Previous Image": "تصویر قبلی",
|
||||
"Next Image": "تصویر بعدی",
|
||||
"Zoomed": "بزرگنمایی شده",
|
||||
"Zoom level": "سطح بزرگنمایی",
|
||||
"Table viewer": "نمایشگر جدول"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "מציג טבלאות"
|
||||
}
|
||||
|
||||
@@ -1031,5 +1031,11 @@
|
||||
"Books": "पुस्तकें",
|
||||
"Groups": "समूह",
|
||||
"Back to TTS Location": "टीटीएस स्थान पर वापस जाएं",
|
||||
"Metadata": "मेटाडाटा"
|
||||
"Metadata": "मेटाडाटा",
|
||||
"Image viewer": "छवि दर्शक",
|
||||
"Previous Image": "पिछली छवि",
|
||||
"Next Image": "अगली छवि",
|
||||
"Zoomed": "ज़ूम किया गया",
|
||||
"Zoom level": "ज़ूम स्तर",
|
||||
"Table viewer": "तालिका दर्शक"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,11 @@
|
||||
"Books": "書籍",
|
||||
"Groups": "グループ",
|
||||
"Back to TTS Location": "TTSの位置に戻る",
|
||||
"Metadata": "メタデータ"
|
||||
"Metadata": "メタデータ",
|
||||
"Image viewer": "画像ビューア",
|
||||
"Previous Image": "前の画像",
|
||||
"Next Image": "次の画像",
|
||||
"Zoomed": "ズーム済み",
|
||||
"Zoom level": "ズームレベル",
|
||||
"Table viewer": "テーブルビューア"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,11 @@
|
||||
"Books": "도서",
|
||||
"Groups": "그룹",
|
||||
"Back to TTS Location": "TTS 위치로 돌아가기",
|
||||
"Metadata": "메타데이터"
|
||||
"Metadata": "메타데이터",
|
||||
"Image viewer": "이미지 뷰어",
|
||||
"Previous Image": "이전 이미지",
|
||||
"Next Image": "다음 이미지",
|
||||
"Zoomed": "확대됨",
|
||||
"Zoom level": "확대 수준",
|
||||
"Table viewer": "테이블 뷰어"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1055,5 +1055,11 @@
|
||||
"Books": "Книги",
|
||||
"Groups": "Группы",
|
||||
"Back to TTS Location": "Вернуться к местоположению TTS",
|
||||
"Metadata": "Метаданные"
|
||||
"Metadata": "Метаданные",
|
||||
"Image viewer": "Просмотрщик изображений",
|
||||
"Previous Image": "Предыдущее изображение",
|
||||
"Next Image": "Следующее изображение",
|
||||
"Zoomed": "Увеличено",
|
||||
"Zoom level": "Уровень масштаба",
|
||||
"Table viewer": "Просмотрщик таблиц"
|
||||
}
|
||||
|
||||
@@ -1031,5 +1031,11 @@
|
||||
"Books": "පොත්",
|
||||
"Groups": "කණ්ඩායම්",
|
||||
"Back to TTS Location": "TTS ස්ථානයට ආපසු යන්න",
|
||||
"Metadata": "මෙටාඩේටා"
|
||||
"Metadata": "මෙටාඩේටා",
|
||||
"Image viewer": "රූප නරඹන්නා",
|
||||
"Previous Image": "පූර්ව රූපය",
|
||||
"Next Image": "මීළඟ රූපය",
|
||||
"Zoomed": "විශාලනය කරන ලදී",
|
||||
"Zoom level": "විශාලන මට්ටම",
|
||||
"Table viewer": "වගු නරඹන්නා"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1031,5 +1031,11 @@
|
||||
"Books": "புத்தகங்கள்",
|
||||
"Groups": "குழுக்கள்",
|
||||
"Back to TTS Location": "TTS இடத்திற்குத் திரும்பு",
|
||||
"Metadata": "மெட்டாடேட்டா"
|
||||
"Metadata": "மெட்டாடேட்டா",
|
||||
"Image viewer": "படக் காட்சிப்பவர்",
|
||||
"Previous Image": "முந்தைய படம்",
|
||||
"Next Image": "அடுத்த படம்",
|
||||
"Zoomed": "பெரிதாக்கப்பட்டது",
|
||||
"Zoom level": "பெரிதாக்கும் நிலை",
|
||||
"Table viewer": "அட்டவணை காட்சிப்பவர்"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,11 @@
|
||||
"Books": "หนังสือ",
|
||||
"Groups": "กลุ่ม",
|
||||
"Back to TTS Location": "กลับไปยังตำแหน่ง TTS",
|
||||
"Metadata": "เมทาดาตา"
|
||||
"Metadata": "เมทาดาตา",
|
||||
"Image viewer": "ตัวดูรูปภาพ",
|
||||
"Previous Image": "รูปภาพก่อนหน้า",
|
||||
"Next Image": "รูปภาพถัดไป",
|
||||
"Zoomed": "ซูมแล้ว",
|
||||
"Zoom level": "ระดับการซูม",
|
||||
"Table viewer": "ตัวดูตาราง"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1055,5 +1055,11 @@
|
||||
"Books": "Книги",
|
||||
"Groups": "Групи",
|
||||
"Back to TTS Location": "Повернутися до розташування TTS",
|
||||
"Metadata": "Метадані"
|
||||
"Metadata": "Метадані",
|
||||
"Image viewer": "Переглядач зображень",
|
||||
"Previous Image": "Попереднє зображення",
|
||||
"Next Image": "Наступне зображення",
|
||||
"Zoomed": "Збільшено",
|
||||
"Zoom level": "Рівень масштабу",
|
||||
"Table viewer": "Переглядач таблиць"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,11 @@
|
||||
"Books": "书籍",
|
||||
"Groups": "分组",
|
||||
"Back to TTS Location": "回到朗读位置",
|
||||
"Metadata": "元数据"
|
||||
"Metadata": "元数据",
|
||||
"Image viewer": "图片查看器",
|
||||
"Previous Image": "上一张图片",
|
||||
"Next Image": "下一张图片",
|
||||
"Zoomed": "已缩放",
|
||||
"Zoom level": "缩放级别",
|
||||
"Table viewer": "表格查看器"
|
||||
}
|
||||
|
||||
@@ -1019,5 +1019,11 @@
|
||||
"Books": "書籍",
|
||||
"Groups": "分組",
|
||||
"Back to TTS Location": "回到朗讀位置",
|
||||
"Metadata": "元數據"
|
||||
"Metadata": "元數據",
|
||||
"Image viewer": "圖片檢視器",
|
||||
"Previous Image": "上一張圖片",
|
||||
"Next Image": "下一張圖片",
|
||||
"Zoomed": "已縮放",
|
||||
"Zoom level": "縮放級別",
|
||||
"Table viewer": "表格檢視器"
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [selectedTableHtml, setSelectedTableHtml] = useState<string | null>(null);
|
||||
const [imageList, setImageList] = useState<{ src: string; cfi: string | null }[]>([]);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState<number>(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 && (
|
||||
<ImageViewer
|
||||
src={selectedImage}
|
||||
onClose={handleCloseImage}
|
||||
onPrevious={currentImageIndex > 0 ? handlePreviousImage : undefined}
|
||||
onNext={currentImageIndex < imageList.length - 1 ? handleNextImage : undefined}
|
||||
/>
|
||||
)}
|
||||
{selectedTableHtml && (
|
||||
<TableViewer
|
||||
html={selectedTableHtml}
|
||||
isDarkMode={isDarkMode}
|
||||
onClose={() => setSelectedTableHtml(null)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import ZoomControls from './ZoomControls';
|
||||
|
||||
interface ImageViewerProps {
|
||||
src: string | null;
|
||||
onClose: () => 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<ImageViewerProps> = ({ 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<number>(0);
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
const wasDragging = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
role='button'
|
||||
aria-label={_('Image viewer')}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleWheel}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md absolute inset-0'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ZoomControls
|
||||
onClose={onClose}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
{onPrevious && showZoomLabel && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviousImage();
|
||||
}}
|
||||
className='eink-bordered absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white transition-all duration-300 hover:bg-black/70'
|
||||
aria-label={_('Previous Image')}
|
||||
title={_('Previous Image')}
|
||||
>
|
||||
<IoChevronBack className='h-8 w-8' />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onNext && showZoomLabel && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNextImage();
|
||||
}}
|
||||
className='eink-bordered absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white transition-all duration-300 hover:bg-black/70'
|
||||
aria-label={_('Next Image')}
|
||||
title={_('Next Image')}
|
||||
>
|
||||
<IoChevronForward className='h-8 w-8' />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
role='none'
|
||||
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden')}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
<Image
|
||||
src={decodeURIComponent(src)}
|
||||
ref={imageRef}
|
||||
alt={_('Zoomed')}
|
||||
className='transform-gpu select-none object-contain'
|
||||
draggable={false}
|
||||
width={0}
|
||||
height={0}
|
||||
sizes='100vw'
|
||||
onClick={handleImageClick}
|
||||
onMouseDown={handleImageMouseDown}
|
||||
onMouseMove={handleImageMouseMove}
|
||||
onMouseUp={handleImageMouseUp}
|
||||
onMouseLeave={handleImageMouseUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
style={{
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
|
||||
transition: 'transform 0.05s ease-out',
|
||||
cursor: cursorStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showZoomLabel && (
|
||||
<div
|
||||
aria-label={_('Zoom level')}
|
||||
className='zoom-level-label eink-bordered not-eink:text-white not-eink:bg-black/50 pointer-events-none absolute left-1/2 top-12 -translate-x-1/2 rounded-full px-3 py-1 text-sm transition-opacity duration-300'
|
||||
>
|
||||
{Math.round((scale * 100) / 5) * 5}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageViewer;
|
||||
@@ -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<TableViewerProps> = ({ 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<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const zoomLabelTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
role='button'
|
||||
aria-label={_('Table viewer')}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='absolute inset-0 bg-black/50 backdrop-blur-md'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ZoomControls
|
||||
onClose={onClose}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onReset={handleResetZoom}
|
||||
/>
|
||||
|
||||
<div
|
||||
role='presentation'
|
||||
className={clsx('relative flex h-full w-full items-center justify-center overflow-hidden')}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
<div
|
||||
role='presentation'
|
||||
ref={contentRef}
|
||||
className='table-viewer-content max-h-full max-w-full transform-gpu select-none overflow-auto rounded-lg shadow-2xl'
|
||||
onClick={handleContentClick}
|
||||
onMouseDown={handleContentMouseDown}
|
||||
onMouseMove={handleContentMouseMove}
|
||||
onMouseUp={handleContentMouseUp}
|
||||
onMouseLeave={handleContentMouseUp}
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(${position.x / scale}px, ${position.y / scale}px)`,
|
||||
transition: 'transform 0.05s ease-out',
|
||||
cursor: cursorStyle,
|
||||
backgroundColor: isDarkMode ? '#1e1e1e' : '#ffffff',
|
||||
color: isDarkMode ? '#ffffff' : '#000000',
|
||||
padding: '24px',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showZoomLabel && (
|
||||
<div
|
||||
aria-label={_('Zoom level')}
|
||||
className='zoom-level-label eink-bordered not-eink:bg-black/50 not-eink:text-white pointer-events-none absolute left-1/2 top-12 -translate-x-1/2 rounded-full px-3 py-1 text-sm transition-opacity duration-300'
|
||||
>
|
||||
{Math.round((scale * 100) / 5) * 5}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.table-viewer-content :global(table) {
|
||||
border-collapse: collapse;
|
||||
border: 1px solid ${isDarkMode ? '#444444' : '#cccccc'};
|
||||
}
|
||||
.table-viewer-content :global(td),
|
||||
.table-viewer-content :global(th) {
|
||||
border: 1px solid ${isDarkMode ? '#444444' : '#cccccc'};
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.table-viewer-content :global(th) {
|
||||
background-color: ${isDarkMode ? '#2a2a2a' : '#f5f5f5'};
|
||||
font-weight: 600;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableViewer;
|
||||
@@ -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<ZoomControlsProps> = ({ onClose, onZoomIn, onZoomOut, onReset }) => {
|
||||
const _ = useTranslation();
|
||||
return (
|
||||
<div className='absolute right-4 top-4 z-10 grid grid-cols-1 gap-4 text-white'>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Close')}
|
||||
title={_('Close')}
|
||||
>
|
||||
<IoClose className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onZoomIn}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Zoom In')}
|
||||
title={_('Zoom In')}
|
||||
>
|
||||
<IoAdd className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onZoomOut}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Zoom Out')}
|
||||
title={_('Zoom Out')}
|
||||
>
|
||||
<IoRemove className='h-6 w-6' />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
|
||||
aria-label={_('Reset Zoom')}
|
||||
title={_('Reset Zoom')}
|
||||
>
|
||||
<IoExpand className='h-6 w-6' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZoomControls;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Element, ReturnType<typeof setTimeout>>();
|
||||
const pressStartPositions = new Map<Element, { x: number; y: number }>();
|
||||
|
||||
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));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -283,3 +283,24 @@ export const getMimeTypeFromFileExt = (ext: string): string => {
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
};
|
||||
|
||||
export const convertBlobUrlToDataUrl = async (blobUrl: string): Promise<string> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user