Просмотр исходного кода

Add swipe as option for gesture navigation between entries.

* Refactor `TouchHandler` to handle double-tap and swipe gestures.
  * Renamed existing `onTouch` JavaScript methods to `onItemTouch` and
    added `onContentTouch` methods for swipe gesture.
  * Refactor double-tap. It's now a method in `TouchHandler` versus
    anonymous functions in `listen()` method.
* Updated CSS classes.
  * Added `touch-action` CSS for `.entry-content`.
  * Renamed CSS classes for adding events in `TouchHandler`.
* Updated users settings to replace checkbox for double tap with select
  for none, double tap, or swipe.
* Added database migrations for new gesture_nav option.
  * Rename `users.double_tap` to `users.gesture_nav` and migrate
    existing user settings.
* Updated translation files. (Non-English updated with Google
  Translate.)

Resolves #1449, closes #1495
dzaikos 3 лет назад
Родитель
Сommit
7d252ea45b

+ 2 - 2
client/model.go

@@ -34,7 +34,7 @@ type User struct {
 	KeyboardShortcuts      bool       `json:"keyboard_shortcuts"`
 	ShowReadingTime        bool       `json:"show_reading_time"`
 	EntrySwipe             bool       `json:"entry_swipe"`
-	DoubleTap              bool       `json:"double_tap"`
+	GestureNav             string     `json:"gesture_nav"`
 	LastLoginAt            *time.Time `json:"last_login_at"`
 	DisplayMode            string     `json:"display_mode"`
 	DefaultReadingSpeed    int        `json:"default_reading_speed"`
@@ -73,7 +73,7 @@ type UserModificationRequest struct {
 	KeyboardShortcuts      *bool   `json:"keyboard_shortcuts"`
 	ShowReadingTime        *bool   `json:"show_reading_time"`
 	EntrySwipe             *bool   `json:"entry_swipe"`
-	DoubleTap              *bool   `json:"double_tap"`
+	GestureNav             *string `json:"gesture_nav"`
 	DisplayMode            *string `json:"display_mode"`
 	DefaultReadingSpeed    *int    `json:"default_reading_speed"`
 	CJKReadingSpeed        *int    `json:"cjk_reading_speed"`

+ 9 - 0
database/migrations.go

@@ -644,4 +644,13 @@ var migrations = []func(tx *sql.Tx) error{
 		`)
 		return
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `
+			ALTER TABLE users RENAME double_tap TO gesture_nav;
+			ALTER TABLE users ALTER COLUMN gesture_nav SET DATA TYPE text using case when gesture_nav = true then 'tap' when gesture_nav = false then 'none' end;
+			ALTER TABLE users ALTER COLUMN gesture_nav SET default 'tap';
+		`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 5 - 1
locale/translations/de_DE.json

@@ -264,6 +264,7 @@
     "error.invalid_timezone": "Ungültige Zeitzone.",
     "error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
     "error.invalid_display_mode": "Progressive Web App (PWA) Anzeigemodus",
+    "error.invalid_gesture_nav": "Ungültige Gestennavigation.",
     "error.invalid_default_home_page": "Ungültige Standard-Startseite!",
     "form.feed.label.title": "Titel",
     "form.feed.label.site_url": "Webseite-URL",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Eintrag erstellt Zeit",
     "form.prefs.select.alphabetical": "Alphabetisch",
     "form.prefs.select.unread_count": "Ungelesen zählen",
+    "form.prefs.select.none": "Keiner",
+    "form.prefs.select.tap": "Doppeltippen",
+    "form.prefs.select.swipe": "Wischen",
     "form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
     "form.prefs.label.entry_swipe": "Aktivieren Sie das Streichen von Einträgen auf Touchscreens",
-    "form.prefs.label.double_tap": "Doppeltippen aktivieren, um zwischen Einträgen zu navigieren",
+    "form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
     "form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
     "form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
     "form.prefs.label.entry_order": "Eintrag Sortierspalte",

+ 5 - 1
locale/translations/el_EL.json

@@ -242,6 +242,7 @@
     "error.invalid_timezone": "Μη έγκυρη ζώνη ώρας.",
     "error.invalid_entry_direction": "Μη έγκυρη κατεύθυνση ταξινόμησης άρθρων.",
     "error.invalid_display_mode": "Μη έγκυρη λειτουργία εμφάνισης εφαρμογών ιστού.",
+    "error.invalid_gesture_nav": "Μη έγκυρη πλοήγηση με χειρονομίες.",
     "error.invalid_default_home_page": "Μη έγκυρη προεπιλεγμένη αρχική σελίδα!",
     "error.empty_file": "Αυτό το αρχείο είναι κενό.",
     "error.bad_credentials": "Μη έγκυρο όνομα χρήστη ή κωδικό πρόσβασης.",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Χρόνος δημιουργίας καταχώρησης",
     "form.prefs.select.alphabetical": "Αλφαβητική σειρά",
     "form.prefs.select.unread_count": "Αριθμός μη αναγνωσμένων",
+    "form.prefs.select.none": "Κανένας",
+    "form.prefs.select.tap": "Διπλό χτύπημα",
+    "form.prefs.select.swipe": "Σουφρώνω",
     "form.prefs.label.keyboard_shortcuts": "Ενεργοποίηση συντομεύσεων πληκτρολογίου",
     "form.prefs.label.entry_swipe": "Ενεργοποιήστε το σάρωση καταχώρισης στις οθόνες αφής",
-    "form.prefs.label.double_tap": "Ενεργοποιήστε το διπλό πάτημα για πλοήγηση μεταξύ των καταχωρήσεων",
+    "form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
     "form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
     "form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
     "form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",

+ 5 - 1
locale/translations/en_US.json

@@ -242,6 +242,7 @@
     "error.invalid_timezone": "Invalid timezone.",
     "error.invalid_entry_direction": "Invalid entry direction.",
     "error.invalid_display_mode": "Invalid web app display mode.",
+    "error.invalid_gesture_nav": "Invalid gesture navigation.",
     "error.invalid_default_home_page": "Invalid default homepage!",
     "error.empty_file": "This file is empty.",
     "error.bad_credentials": "Invalid username or password.",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Entry created time",
     "form.prefs.select.alphabetical": "Alphabetical",
     "form.prefs.select.unread_count": "Unread count",
+    "form.prefs.select.none": "None",
+    "form.prefs.select.tap": "Double tap",
+    "form.prefs.select.swipe": "Swipe",
     "form.prefs.label.keyboard_shortcuts": "Enable keyboard shortcuts",
     "form.prefs.label.entry_swipe": "Enable entry swipe on touch screens",
-    "form.prefs.label.double_tap": "Enable double tap to navigate between entries",
+    "form.prefs.label.gesture_nav": "Gesture to navigate between entries",
     "form.prefs.label.show_reading_time": "Show estimated reading time for entries",
     "form.prefs.label.custom_css": "Custom CSS",
     "form.prefs.label.entry_order": "Entry sorting column",

+ 5 - 1
locale/translations/es_ES.json

@@ -264,6 +264,7 @@
     "error.invalid_timezone": "Zona horaria no válida.",
     "error.invalid_entry_direction": "Dirección de artículo no válida.",
     "error.invalid_display_mode": "Modo de visualización de la aplicación web no válido.",
+    "error.invalid_gesture_nav": "Navegación por gestos no válida.",
     "error.invalid_default_home_page": "¡Página de inicio por defecto no válida!",
     "form.feed.label.title": "Título",
     "form.feed.label.site_url": "URL del sitio",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Hora de creación del artículo",
     "form.prefs.select.alphabetical": "Alfabético",
     "form.prefs.select.unread_count": "Recuento de no leídos",
+    "form.prefs.select.none": "Ninguno",
+    "form.prefs.select.tap": "Doble toque",
+    "form.prefs.select.swipe": "Golpe fuerte",
     "form.prefs.label.keyboard_shortcuts": "Habilitar atajos de teclado",
     "form.prefs.label.entry_swipe": "Habilitar deslizamiento de entrada en pantallas táctiles",
-    "form.prefs.label.double_tap": "Habilite el doble toque para navegar entre las entradas",
+    "form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
     "form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
     "form.prefs.label.custom_css": "CSS personalizado",
     "form.prefs.label.entry_order": "Columna de clasificación de artículos",

+ 5 - 1
locale/translations/fi_FI.json

@@ -242,6 +242,7 @@
     "error.invalid_timezone": "Virheellinen aikavyöhyke.",
     "error.invalid_entry_direction": "Invalid entry direction.",
     "error.invalid_display_mode": "Virheellinen verkkosovelluksen näyttötila.",
+    "error.invalid_gesture_nav": "Virheellinen ele-navigointi.",
     "error.invalid_default_home_page": "Väärä oletusarvoinen kotisivu!",
     "error.empty_file": "Tiedosto on tyhjä.",
     "error.bad_credentials": "Virheellinen käyttäjänimi tai salasana.",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Luomisaika",
     "form.prefs.select.alphabetical": "Aakkosjärjestys",
     "form.prefs.select.unread_count": "Lukemattomien määrä",
+    "form.prefs.select.none": "Ei mitään",
+    "form.prefs.select.tap": "Kaksoisnapauta",
+    "form.prefs.select.swipe": "Pyyhkäise",
     "form.prefs.label.keyboard_shortcuts": "Ota pikanäppäimet käyttöön",
     "form.prefs.label.entry_swipe": "Ota syöttöpyyhkäisy käyttöön kosketusnäytöissä",
-    "form.prefs.label.double_tap": "Ota kaksoisnapautus käyttöön siirtyäksesi merkintöjen välillä",
+    "form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
     "form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
     "form.prefs.label.custom_css": "Mukautettu CSS",
     "form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",

+ 5 - 1
locale/translations/fr_FR.json

@@ -264,6 +264,7 @@
     "error.invalid_timezone": "Fuseau horaire non valide.",
     "error.invalid_entry_direction": "Ordre de trie non valide.",
     "error.invalid_display_mode": "Mode d'affichage de l'application web non valide.",
+    "error.invalid_gesture_nav": "Navigation gestuelle non valide.",
     "error.invalid_default_home_page": "Page d'accueil par défaut invalide !",
     "form.feed.label.title": "Titre",
     "form.feed.label.site_url": "URL du site web",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Heure de création de l'entrée",
     "form.prefs.select.alphabetical": "Alphabétique",
     "form.prefs.select.unread_count": "Nombre d'articles non lus",
+    "form.prefs.select.none": "Aucun",
+    "form.prefs.select.tap": "Tapez deux fois",
+    "form.prefs.select.swipe": "Glisser",
     "form.prefs.label.keyboard_shortcuts": "Activer les raccourcis clavier",
     "form.prefs.label.entry_swipe": "Activer le balayage des entrées sur les écrans tactiles",
-    "form.prefs.label.double_tap": "Activer le double tap pour naviguer entre les entrées",
+    "form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
     "form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
     "form.prefs.label.custom_css": "CSS personnalisé",
     "form.prefs.label.entry_order": "Colonne de tri des entrées",

+ 5 - 1
locale/translations/hi_IN.json

@@ -242,6 +242,7 @@
     "error.invalid_timezone": "अमान्य समयक्षेत्र.",
     "error.invalid_entry_direction": "अमान्य प्रवेश दिशा।",
     "error.invalid_display_mode": "अमान्य वेब ऐप्लिकेशन प्रदर्शन मोड.",
+    "error.invalid_gesture_nav": "अमान्य इशारा नेविगेशन।",
     "error.invalid_default_home_page": "अमान्य डिफ़ॉल्ट मुखपृष्ठ!",
     "error.empty_file": "यह फ़ाइल खाली है।",
     "error.bad_credentials": "अमान्य उपयोगकर्ता नाम या पासवर्ड।",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "प्रवेश बनाया समय",
     "form.prefs.select.alphabetical": "वर्णक्रम",
     "form.prefs.select.unread_count": "अपठित गणना",
+    "form.prefs.select.none": "कोई नहीं",
+    "form.prefs.select.tap": "दो बार टैप",
+    "form.prefs.select.swipe": "कड़ी चोट",
     "form.prefs.label.keyboard_shortcuts": "कीबोर्ड शॉर्टकट सक्षम करें",
     "form.prefs.label.entry_swipe": "टच स्क्रीन पर एंट्री स्वाइप सक्षम करें",
-    "form.prefs.label.double_tap": "प्रविष्टियों के बीच नेविगेट करने के लिए डबल टैप सक्षम करें",
+    "form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
     "form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
     "form.prefs.label.custom_css": "कस्टम सीएसएस",
     "form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",

+ 5 - 1
locale/translations/id_ID.json

@@ -239,6 +239,7 @@
     "error.invalid_timezone": "Zona waktu tidak valid.",
     "error.invalid_entry_direction": "Urutan entri tidak valid.",
     "error.invalid_display_mode": "Mode tampilan aplikasi web tidak valid.",
+    "error.invalid_gesture_nav": "Navigasi gestur tidak valid.",
     "error.invalid_default_home_page": "Beranda baku tidak valid!",
     "error.empty_file": "Berkas ini kosong.",
     "error.bad_credentials": "Nama pengguna atau kata sandi tidak valid.",
@@ -305,9 +306,12 @@
     "form.prefs.select.created_time": "Waktu entri dibuat",
     "form.prefs.select.alphabetical": "Secara alfabet",
     "form.prefs.select.unread_count": "Jumlah yang belum dibaca",
+    "form.prefs.select.none": "Tidak ada",
+    "form.prefs.select.tap": "Ketuk dua kali",
+    "form.prefs.select.swipe": "Geser",
     "form.prefs.label.keyboard_shortcuts": "Aktifkan pintasan papan tik",
     "form.prefs.label.entry_swipe": "Aktifkan tindakan geser pada entri di ponsel",
-    "form.prefs.label.double_tap": "Aktifkan ketuk dua kali untuk navigasi antar entri",
+    "form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
     "form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
     "form.prefs.label.custom_css": "Modifikasi CSS",
     "form.prefs.label.entry_order": "Pengurutan Kolom Entri",

+ 5 - 1
locale/translations/it_IT.json

@@ -264,6 +264,7 @@
     "error.invalid_timezone": "Fuso orario non valido.",
     "error.invalid_entry_direction": "Ordinamento non valido.",
     "error.invalid_display_mode": "Modalità di visualizzazione web app non valida.",
+    "error.invalid_gesture_nav": "Navigazione gestuale non valida.",
     "error.invalid_default_home_page": "Pagina iniziale predefinita non valida!",
     "form.feed.label.title": "Titolo",
     "form.feed.label.site_url": "URL del sito",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Tempo di creazione dell'entrata",
     "form.prefs.select.alphabetical": "In ordine alfabetico",
     "form.prefs.select.unread_count": "Conteggio dei non letti",
+    "form.prefs.select.none": "Nessuno",
+    "form.prefs.select.tap": "Tocca due volte",
+    "form.prefs.select.swipe": "Scorri",
     "form.prefs.label.keyboard_shortcuts": "Abilita le scorciatoie da tastiera",
     "form.prefs.label.entry_swipe": "Abilita lo scorrimento della voce sui touch screen",
-    "form.prefs.label.double_tap": "Abilita il doppio tocco per navigare tra le voci",
+    "form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
     "form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
     "form.prefs.label.custom_css": "CSS personalizzati",
     "form.prefs.label.entry_order": "Colonna di ordinamento delle voci",

+ 5 - 1
locale/translations/ja_JP.json

@@ -242,6 +242,7 @@
     "error.invalid_timezone": "タイムゾーンが無効です。",
     "error.invalid_entry_direction": "記事の表示順が無効です。",
     "error.invalid_display_mode": "Web アプリの表示モードが無効です。",
+    "error.invalid_gesture_nav": "ジェスチャー ナビゲーションが無効です。",
     "error.invalid_default_home_page": "デフォルトのトップページが無効です",
     "error.empty_file": "このファイルは空です。",
     "error.bad_credentials": "ユーザー名かパスワードが間違っています。",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "記事の取得時刻",
     "form.prefs.select.alphabetical": "アルファベット順",
     "form.prefs.select.unread_count": "未読数",
+    "form.prefs.select.none": "なし",
+    "form.prefs.select.tap": "ダブルタップ",
+    "form.prefs.select.swipe": "スワイプ",
     "form.prefs.label.keyboard_shortcuts": "キーボードショートカットを有効にする",
     "form.prefs.label.entry_swipe": "タッチスクリーンでスワイプ入力を有効にする",
-    "form.prefs.label.double_tap": "ダブルタップで記事間を移動する",
+    "form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
     "form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
     "form.prefs.label.custom_css": "カスタム CSS",
     "form.prefs.label.entry_order": "記事の表示順の基準",

+ 5 - 1
locale/translations/nl_NL.json

@@ -264,6 +264,7 @@
     "error.invalid_timezone": "Ongeldige tijdzone.",
     "error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
     "error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
+    "error.invalid_gesture_nav": "Ongeldige gebarennavigatie.",
     "error.invalid_default_home_page": "Ongeldige standaard homepage!",
     "form.feed.label.title": "Naam",
     "form.feed.label.site_url": "Website URL",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Tijdstip van binnenkomst",
     "form.prefs.select.alphabetical": "Alfabetisch",
     "form.prefs.select.unread_count": "Ongelezen tellen",
+    "form.prefs.select.none": "Geen",
+    "form.prefs.select.tap": "Dubbeltik",
+    "form.prefs.select.swipe": "Vegen",
     "form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
     "form.prefs.label.entry_swipe": "Invoervegen inschakelen op aanraakschermen",
-    "form.prefs.label.double_tap": "Schakel dubbeltikken in om tussen vermeldingen te navigeren",
+    "form.prefs.label.gesture_nav": "Gebaar om tussen ingangen te navigeren",
     "form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
     "form.prefs.label.custom_css": "Aangepaste CSS",
     "form.prefs.label.entry_order": "Ingang Sorteerkolom",

+ 5 - 1
locale/translations/pl_PL.json

@@ -266,6 +266,7 @@
     "error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
     "error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
     "error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji internetowej.",
+    "error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
     "error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
     "form.feed.label.title": "Tytuł",
     "form.feed.label.site_url": "URL strony",
@@ -303,7 +304,7 @@
     "form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
     "form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",
     "form.prefs.label.entry_swipe": "Włącz machnięcie wpisu na ekranach dotykowych",
-    "form.prefs.label.double_tap": "Włącz podwójne dotknięcie, aby przechodzić między wpisami",
+    "form.prefs.label.gesture_nav": "Gest, aby poruszać się między wpisami",
     "form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania artykułów",
     "form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
     "form.prefs.select.fullscreen": "Pełny ekran",
@@ -314,6 +315,9 @@
     "form.prefs.select.created_time": "Czas utworzenia wpisu",
     "form.prefs.select.alphabetical": "Alfabetycznie",
     "form.prefs.select.unread_count": "Liczba nieprzeczytanych",
+    "form.prefs.select.none": "Nic",
+    "form.prefs.select.tap": "Podwójne wciśnięcie",
+    "form.prefs.select.swipe": "Trzepnąć",
     "form.prefs.label.custom_css": "Niestandardowy CSS",
     "form.prefs.label.entry_order": "Kolumna sortowania wpisów",
     "form.prefs.label.default_home_page": "Domyślna strona główna",

+ 5 - 1
locale/translations/pt_BR.json

@@ -264,6 +264,7 @@
     "error.invalid_timezone": "Fuso horário inválido.",
     "error.invalid_entry_direction": "Direção de entrada inválida.",
     "error.invalid_display_mode": "Modo de exibição de aplicativo inválido da web.",
+    "error.invalid_gesture_nav": "Navegação por gestos inválida.",
     "error.invalid_default_home_page": "Página inicial por defeito inválida!",
     "form.feed.label.title": "Título",
     "form.feed.label.site_url": "URL do site",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Entrada tempo criado",
     "form.prefs.select.alphabetical": "Por ordem alfabética",
     "form.prefs.select.unread_count": "Contagem não lida",
+    "form.prefs.select.none": "Nenhum",
+    "form.prefs.select.tap": "Toque duplo",
+    "form.prefs.select.swipe": "Deslize",
     "form.prefs.label.keyboard_shortcuts": "Habilitar atalhos do teclado",
     "form.prefs.label.entry_swipe": "Ativar entrada de furto em telas sensíveis ao toque",
-    "form.prefs.label.double_tap": "Ative o toque duplo para navegar entre as entradas",
+    "form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
     "form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
     "form.prefs.label.custom_css": "CSS customizado",
     "form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",

+ 5 - 1
locale/translations/ru_RU.json

@@ -266,6 +266,7 @@
     "error.invalid_timezone": "Неверный часовой пояс.",
     "error.invalid_entry_direction": "Неверное направление входа.",
     "error.invalid_display_mode": "Недопустимый режим отображения веб-приложения.",
+    "error.invalid_gesture_nav": "Неверная жестовая навигация.",
     "error.invalid_default_home_page": "Неверная домашняя страница по умолчанию!",
     "form.feed.label.title": "Название",
     "form.feed.label.site_url": "URL сайта",
@@ -310,9 +311,12 @@
     "form.prefs.select.created_time": "Время создания записи",
     "form.prefs.select.alphabetical": "По алфавиту",
     "form.prefs.select.unread_count": "Количество непрочитанных",
+    "form.prefs.select.none": "Никто",
+    "form.prefs.select.tap": "Двойное нажатие",
+    "form.prefs.select.swipe": "Проведите",
     "form.prefs.label.keyboard_shortcuts": "Включить сочетания клавиш",
     "form.prefs.label.entry_swipe": "Включить пролистывание ввода на сенсорных экранах",
-    "form.prefs.label.double_tap": "Включить двойное касание для перехода между записями",
+    "form.prefs.label.gesture_nav": "Жест для перехода между записями",
     "form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
     "form.prefs.label.custom_css": "Пользовательские CSS",
     "form.prefs.label.entry_order": "Колонка сортировки ввода",

+ 5 - 1
locale/translations/tr_TR.json

@@ -242,6 +242,7 @@
     "error.invalid_timezone": "Geçersiz saat dilimi",
     "error.invalid_entry_direction": "Geçersiz giriş yönü.",
     "error.invalid_display_mode": "Geçersiz web uygulaması görüntüleme modu.",
+    "error.invalid_gesture_nav": "Hareketle gezinme geçersiz.",
     "error.invalid_default_home_page": "Geçersiz varsayılan ana sayfa!",
     "error.empty_file": "Bu dosya boş.",
     "error.bad_credentials": "Geçersiz kullanıcı veya parola.",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "Girişin oluşturulma zamanı",
     "form.prefs.select.alphabetical": "Alfabetik",
     "form.prefs.select.unread_count": "Okunmamış sayısı",
+    "form.prefs.select.none": "Hiçbiri",
+    "form.prefs.select.tap": "çift dokunma",
+    "form.prefs.select.swipe": "Tokatlamak",
     "form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
     "form.prefs.label.entry_swipe": "Увімкніть введення пальцем на сенсорних екранах",
-    "form.prefs.label.double_tap": "Girişler arasında gezinmek için çift dokunmayı etkinleştirin",
+    "form.prefs.label.gesture_nav": "Girişler arasında gezinmek için hareket",
     "form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
     "form.prefs.label.custom_css": "Özel CSS",
     "form.prefs.label.entry_order": "Giriş Sıralama Sütunu",

+ 5 - 1
locale/translations/uk_UA.json

@@ -241,6 +241,7 @@
   "error.invalid_timezone": "Недійсний часовий пояс.",
   "error.invalid_entry_direction": "Недійсний напрямок запису.",
   "error.invalid_display_mode": "Недійсний режим відображення.",
+  "error.invalid_gesture_nav": "Недійсна навігація жестами.",
   "error.invalid_default_home_page": "Недійсна домашня сторінка за замовчуванням!",
   "error.empty_file": "Цей файл порожній.",
   "error.bad_credentials": "Невірне ім’я користувача або пароль.",
@@ -307,9 +308,12 @@
   "form.prefs.select.created_time": "Дата створення запису",
   "form.prefs.select.alphabetical": "За алфавітом",
   "form.prefs.select.unread_count": "Кількість непрочитаних",
+  "form.prefs.select.none": "Жодного",
+  "form.prefs.select.tap": "Двічі натисніть",
+  "form.prefs.select.swipe": "Проведіть пальцем",
   "form.prefs.label.keyboard_shortcuts": "Увімкнути комбінації клавиш",
   "form.prefs.label.entry_swipe": "Увімкніть введення пальцем на сенсорних екранах",
-  "form.prefs.label.double_tap": "Увімкніть подвійне торкання, щоб переходити між записами",
+  "form.prefs.label.gesture_nav": "Жест для переходу між записами",
   "form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
   "form.prefs.label.custom_css": "Спеціальний CSS",
   "form.prefs.label.entry_order": "Стовпець сортування записів",

+ 5 - 1
locale/translations/zh_CN.json

@@ -262,6 +262,7 @@
     "error.invalid_timezone": "无效的时区。",
     "error.invalid_entry_direction": "无效的输入方向。",
     "error.invalid_display_mode": "无效的网页应用显示模式。",
+    "error.invalid_gesture_nav": "手势导航无效。",
     "error.invalid_default_home_page": "无效的默认主页!",
     "form.feed.label.title": "标题",
     "form.feed.label.site_url": "源网站 URL",
@@ -306,9 +307,12 @@
     "form.prefs.select.created_time": "文章创建时间",
     "form.prefs.select.alphabetical": "按字母顺序",
     "form.prefs.select.unread_count": "未读计数",
+    "form.prefs.select.none": "没有任何",
+    "form.prefs.select.tap": "双击",
+    "form.prefs.select.swipe": "滑动",
     "form.prefs.label.keyboard_shortcuts": "启用键盘快捷键",
     "form.prefs.label.entry_swipe": "在触摸屏上启用输入滑动",
-    "form.prefs.label.double_tap": "启用双击以在条目之间导航",
+    "form.prefs.label.gesture_nav": "在条目之间导航的手势",
     "form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
     "form.prefs.label.custom_css": "自定义 CSS",
     "form.prefs.label.entry_order": "文章排序依据",

+ 5 - 1
locale/translations/zh_TW.json

@@ -264,6 +264,7 @@
     "error.invalid_timezone": "無效的時區。",
     "error.invalid_entry_direction": "無效的輸入方向。",
     "error.invalid_display_mode": "無效的網頁應用顯示模式。",
+    "error.invalid_gesture_nav": "手勢導航無效.",
     "error.invalid_default_home_page": "默認主頁無效!",
     "form.feed.label.title": "標題",
     "form.feed.label.site_url": "網站 URL",
@@ -308,9 +309,12 @@
     "form.prefs.select.created_time": "文章建立時間",
     "form.prefs.select.alphabetical": "按字母順序",
     "form.prefs.select.unread_count": "未讀計數",
+    "form.prefs.select.none": "沒有任何",
+    "form.prefs.select.tap": "雙擊",
+    "form.prefs.select.swipe": "滑動",
     "form.prefs.label.keyboard_shortcuts": "啟用鍵盤快捷鍵",
     "form.prefs.label.entry_swipe": "在触摸屏上启用输入滑动",
-    "form.prefs.label.double_tap": "啟用雙擊以在條目之間導航",
+    "form.prefs.label.gesture_nav": "在條目之間導航的手勢",
     "form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
     "form.prefs.label.custom_css": "自定義 CSS",
     "form.prefs.label.entry_order": "文章排序依據",

+ 4 - 4
model/user.go

@@ -28,7 +28,7 @@ type User struct {
 	KeyboardShortcuts      bool       `json:"keyboard_shortcuts"`
 	ShowReadingTime        bool       `json:"show_reading_time"`
 	EntrySwipe             bool       `json:"entry_swipe"`
-	DoubleTap              bool       `json:"double_tap"`
+	GestureNav             string     `json:"gesture_nav"`
 	LastLoginAt            *time.Time `json:"last_login_at"`
 	DisplayMode            string     `json:"display_mode"`
 	DefaultReadingSpeed    int        `json:"default_reading_speed"`
@@ -63,7 +63,7 @@ type UserModificationRequest struct {
 	KeyboardShortcuts      *bool   `json:"keyboard_shortcuts"`
 	ShowReadingTime        *bool   `json:"show_reading_time"`
 	EntrySwipe             *bool   `json:"entry_swipe"`
-	DoubleTap              *bool   `json:"double_tap"`
+	GestureNav             *string `json:"gesture_nav"`
 	DisplayMode            *string `json:"display_mode"`
 	DefaultReadingSpeed    *int    `json:"default_reading_speed"`
 	CJKReadingSpeed        *int    `json:"cjk_reading_speed"`
@@ -133,8 +133,8 @@ func (u *UserModificationRequest) Patch(user *User) {
 		user.EntrySwipe = *u.EntrySwipe
 	}
 
-	if u.DoubleTap != nil {
-		user.DoubleTap = *u.DoubleTap
+	if u.GestureNav != nil {
+		user.GestureNav = *u.GestureNav
 	}
 
 	if u.DisplayMode != nil {

+ 13 - 13
storage/user.go

@@ -81,7 +81,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
 			keyboard_shortcuts,
 			show_reading_time,
 			entry_swipe,
-			double_tap,
+			gesture_nav,
 			stylesheet,
 			google_id,
 			openid_connect_id,
@@ -118,7 +118,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
 		&user.KeyboardShortcuts,
 		&user.ShowReadingTime,
 		&user.EntrySwipe,
-		&user.DoubleTap,
+		&user.GestureNav,
 		&user.Stylesheet,
 		&user.GoogleID,
 		&user.OpenIDConnectID,
@@ -174,7 +174,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 				keyboard_shortcuts=$9,
 				show_reading_time=$10,
 				entry_swipe=$11,
-				double_tap=$12,
+				gesture_nav=$12,
 				stylesheet=$13,
 				google_id=$14,
 				openid_connect_id=$15,
@@ -201,7 +201,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 			user.KeyboardShortcuts,
 			user.ShowReadingTime,
 			user.EntrySwipe,
-			user.DoubleTap,
+			user.GestureNav,
 			user.Stylesheet,
 			user.GoogleID,
 			user.OpenIDConnectID,
@@ -229,7 +229,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 				keyboard_shortcuts=$8,
 				show_reading_time=$9,
 				entry_swipe=$10,
-				double_tap=$11,
+				gesture_nav=$11,
 				stylesheet=$12,
 				google_id=$13,
 				openid_connect_id=$14,
@@ -255,7 +255,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
 			user.KeyboardShortcuts,
 			user.ShowReadingTime,
 			user.EntrySwipe,
-			user.DoubleTap,
+			user.GestureNav,
 			user.Stylesheet,
 			user.GoogleID,
 			user.OpenIDConnectID,
@@ -301,7 +301,7 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
 			keyboard_shortcuts,
 			show_reading_time,
 			entry_swipe,
-			double_tap,
+			gesture_nav,
 			last_login_at,
 			stylesheet,
 			google_id,
@@ -335,7 +335,7 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
 			keyboard_shortcuts,
 			show_reading_time,
 			entry_swipe,
-			double_tap,
+			gesture_nav,
 			last_login_at,
 			stylesheet,
 			google_id,
@@ -369,7 +369,7 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
 			keyboard_shortcuts,
 			show_reading_time,
 			entry_swipe,
-			double_tap,
+			gesture_nav,
 			last_login_at,
 			stylesheet,
 			google_id,
@@ -410,7 +410,7 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
 			u.keyboard_shortcuts,
 			u.show_reading_time,
 			u.entry_swipe,
-			u.double_tap,
+			u.gesture_nav,
 			u.last_login_at,
 			u.stylesheet,
 			u.google_id,
@@ -445,7 +445,7 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
 		&user.KeyboardShortcuts,
 		&user.ShowReadingTime,
 		&user.EntrySwipe,
-		&user.DoubleTap,
+		&user.GestureNav,
 		&user.LastLoginAt,
 		&user.Stylesheet,
 		&user.GoogleID,
@@ -542,7 +542,7 @@ func (s *Storage) Users() (model.Users, error) {
 			keyboard_shortcuts,
 			show_reading_time,
 			entry_swipe,
-			double_tap,
+			gesture_nav,
 			last_login_at,
 			stylesheet,
 			google_id,
@@ -578,7 +578,7 @@ func (s *Storage) Users() (model.Users, error) {
 			&user.KeyboardShortcuts,
 			&user.ShowReadingTime,
 			&user.EntrySwipe,
-			&user.DoubleTap,
+			&user.GestureNav,
 			&user.LastLoginAt,
 			&user.Stylesheet,
 			&user.GoogleID,

+ 1 - 1
template/templates/views/entry.html

@@ -143,7 +143,7 @@
     </div>
     {{ end }}
     {{ end }}
-    <article role="article" class="entry-content {{ if $.user.DoubleTap }}double-tap{{ end }}" dir="auto">
+    <article role="article" class="entry-content gesture-nav-{{ $.user.GestureNav }}" dir="auto">
         {{ if .user }}
             {{ noescape (proxyFilter .entry.Content) }}
         {{ else }}

+ 6 - 1
template/templates/views/settings.html

@@ -90,7 +90,12 @@
 
     <label><input type="checkbox" name="entry_swipe" value="1" {{ if .form.EntrySwipe }}checked{{ end }}> {{ t "form.prefs.label.entry_swipe" }}</label>
 
-    <label><input type="checkbox" name="double_tap" value="1" {{ if .form.DoubleTap }}checked{{ end }}> {{ t "form.prefs.label.double_tap" }}</label>
+    <label for="form-gesture-nav">{{ t "form.prefs.label.gesture_nav" }}</label>
+    <select id="form-gesture-nav" name="gesture_nav">
+        <option value="none" {{ if eq "none" $.form.GestureNav }}selected="selected"{{ end }}>{{ t "form.prefs.select.none" }}</option>
+        <option value="tap" {{ if eq "tap" $.form.GestureNav }}selected="selected"{{ end }}>{{ t "form.prefs.select.tap" }}</option>
+        <option value="swipe" {{ if eq "swipe" $.form.GestureNav }}selected="selected"{{ end }}>{{ t "form.prefs.select.swipe" }}</option>
+    </select>
 
     <label><input type="checkbox" name="show_reading_time" value="1" {{ if .form.ShowReadingTime }}checked{{ end }}> {{ t "form.prefs.label.show_reading_time" }}</label>
 

+ 4 - 0
tests/user_test.go

@@ -88,6 +88,10 @@ func TestGetUsers(t *testing.T) {
 		t.Fatalf(`Invalid web app display mode, got "%v"`, users[0].DisplayMode)
 	}
 
+	if users[0].GestureNav != "tap" {
+		t.Fatalf(`Invalid gesture navigation, got "%v"`, users[0].GestureNav)
+	}
+
 	if users[0].DefaultReadingSpeed != 265 {
 		t.Fatalf(`Invalid default reading speed, got "%v"`, users[0].DefaultReadingSpeed)
 	}

+ 3 - 3
ui/form/settings.go

@@ -27,7 +27,7 @@ type SettingsForm struct {
 	ShowReadingTime        bool
 	CustomCSS              string
 	EntrySwipe             bool
-	DoubleTap              bool
+	GestureNav             string
 	DisplayMode            string
 	DefaultReadingSpeed    int
 	CJKReadingSpeed        int
@@ -48,7 +48,7 @@ func (s *SettingsForm) Merge(user *model.User) *model.User {
 	user.ShowReadingTime = s.ShowReadingTime
 	user.Stylesheet = s.CustomCSS
 	user.EntrySwipe = s.EntrySwipe
-	user.DoubleTap = s.DoubleTap
+	user.GestureNav = s.GestureNav
 	user.DisplayMode = s.DisplayMode
 	user.CJKReadingSpeed = s.CJKReadingSpeed
 	user.DefaultReadingSpeed = s.DefaultReadingSpeed
@@ -114,7 +114,7 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
 		ShowReadingTime:        r.FormValue("show_reading_time") == "1",
 		CustomCSS:              r.FormValue("custom_css"),
 		EntrySwipe:             r.FormValue("entry_swipe") == "1",
-		DoubleTap:              r.FormValue("double_tap") == "1",
+		GestureNav:             r.FormValue("gesture_nav"),
 		DisplayMode:            r.FormValue("display_mode"),
 		DefaultReadingSpeed:    int(defaultReadingSpeed),
 		CJKReadingSpeed:        int(cjkReadingSpeed),

+ 3 - 0
ui/form/settings_test.go

@@ -15,6 +15,7 @@ func TestValid(t *testing.T) {
 		EntryDirection:      "asc",
 		EntriesPerPage:      50,
 		DisplayMode:         "standalone",
+		GestureNav:          "tap",
 		DefaultReadingSpeed: 35,
 		CJKReadingSpeed:     25,
 		DefaultHomePage:     "unread",
@@ -37,6 +38,7 @@ func TestConfirmationEmpty(t *testing.T) {
 		EntryDirection:      "asc",
 		EntriesPerPage:      50,
 		DisplayMode:         "standalone",
+		GestureNav:          "tap",
 		DefaultReadingSpeed: 35,
 		CJKReadingSpeed:     25,
 		DefaultHomePage:     "unread",
@@ -63,6 +65,7 @@ func TestConfirmationIncorrect(t *testing.T) {
 		EntryDirection:      "asc",
 		EntriesPerPage:      50,
 		DisplayMode:         "standalone",
+		GestureNav:          "tap",
 		DefaultReadingSpeed: 35,
 		CJKReadingSpeed:     25,
 		DefaultHomePage:     "unread",

+ 1 - 1
ui/settings_show.go

@@ -38,7 +38,7 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
 		ShowReadingTime:        user.ShowReadingTime,
 		CustomCSS:              user.Stylesheet,
 		EntrySwipe:             user.EntrySwipe,
-		DoubleTap:              user.DoubleTap,
+		GestureNav:             user.GestureNav,
 		DisplayMode:            user.DisplayMode,
 		DefaultReadingSpeed:    user.DefaultReadingSpeed,
 		CJKReadingSpeed:        user.CJKReadingSpeed,

+ 1 - 0
ui/settings_update.go

@@ -61,6 +61,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
 		EntryDirection:      model.OptionalString(settingsForm.EntryDirection),
 		EntriesPerPage:      model.OptionalInt(settingsForm.EntriesPerPage),
 		DisplayMode:         model.OptionalString(settingsForm.DisplayMode),
+		GestureNav:          model.OptionalString(settingsForm.GestureNav),
 		DefaultReadingSpeed: model.OptionalInt(settingsForm.DefaultReadingSpeed),
 		CJKReadingSpeed:     model.OptionalInt(settingsForm.CJKReadingSpeed),
 		DefaultHomePage:     model.OptionalString(settingsForm.DefaultHomePage),

+ 1 - 0
ui/static/css/common.css

@@ -882,6 +882,7 @@ article.category-has-unread {
     color: var(--entry-content-color);
     line-height: 1.4em;
     overflow-wrap: break-word;
+    touch-action: pan-y pinch-zoom;
 }
 
 .entry-content h1, h2, h3, h4, h5, h6 {

+ 88 - 39
ui/static/js/touch_handler.js

@@ -8,6 +8,7 @@ class TouchHandler {
             start: { x: -1, y: -1 },
             move: { x: -1, y: -1 },
             moved: false,
+            time: 0,
             element: null
         };
     }
@@ -33,7 +34,7 @@ class TouchHandler {
         return DomHelper.findParent(element, "entry-swipe");
     }
 
-    onTouchStart(event) {
+    onItemTouchStart(event) {
         if (event.touches === undefined || event.touches.length !== 1) {
             return;
         }
@@ -45,7 +46,7 @@ class TouchHandler {
         this.touch.element.style.transitionDuration = "0s";
     }
 
-    onTouchMove(event) {
+    onItemTouchMove(event) {
         if (event.touches === undefined || event.touches.length !== 1 || this.element === null) {
             return;
         }
@@ -71,15 +72,15 @@ class TouchHandler {
         }
     }
 
-    onTouchEnd(event) {
+    onItemTouchEnd(event) {
         if (event.touches === undefined) {
             return;
         }
 
         if (this.touch.element !== null) {
-            let distance = Math.abs(this.calculateDistance());
+            let absDistance = Math.abs(this.calculateDistance());
 
-            if (distance > 75) {
+            if (absDistance > 75) {
                 toggleEntryStatus(this.touch.element);
             }
 
@@ -92,47 +93,95 @@ class TouchHandler {
         this.reset();
     }
 
+    onContentTouchStart(event) {
+        if (event.touches === undefined || event.touches.length !== 1) {
+            return;
+        }
+
+        this.reset();
+        this.touch.start.x = event.touches[0].clientX;
+        this.touch.start.y = event.touches[0].clientY;
+        this.touch.time = Date.now();
+    }
+
+    onContentTouchMove(event) {
+        if (event.touches === undefined || event.touches.length !== 1 || this.element === null) {
+            return;
+        }
+
+        this.touch.move.x = event.touches[0].clientX;
+        this.touch.move.y = event.touches[0].clientY;
+    }
+
+    onContentTouchEnd(event) {
+        if (event.touches === undefined) {
+            return;
+        }
+
+        let distance = this.calculateDistance();
+        let absDistance = Math.abs(distance);
+        let now = Date.now();
+
+        if (now - this.touch.time <= 1000 && absDistance > 75) {
+            if (distance > 0) {
+                goToPage("previous");
+            } else {
+                goToPage("next");
+            }
+        }
+
+        this.reset();
+    }
+
+    onTapEnd(event) {
+        if (event.touches === undefined) {
+            return;
+        }
+
+        let now = Date.now();
+
+        if (this.touch.start.x !== -1 && now - this.touch.time <= 200) {
+            let innerWidthHalf = window.innerWidth / 2;
+
+            if (this.touch.start.x >= innerWidthHalf && event.changedTouches[0].clientX >= innerWidthHalf) {
+                goToPage("next");
+            } else if (this.touch.start.x < innerWidthHalf && event.changedTouches[0].clientX < innerWidthHalf) {
+                goToPage("previous");
+            }
+
+            this.reset();
+        } else {
+            this.reset();
+            this.touch.start.x = event.changedTouches[0].clientX;
+            this.touch.time = now;
+        }
+    }
+
     listen() {
-        let elements = document.querySelectorAll(".entry-swipe");
         let hasPassiveOption = DomHelper.hasPassiveEventListenerOption();
 
+        let elements = document.querySelectorAll(".entry-swipe");
+
         elements.forEach((element) => {
-            element.addEventListener("touchstart", (e) => this.onTouchStart(e), hasPassiveOption ? { passive: true } : false);
-            element.addEventListener("touchmove", (e) => this.onTouchMove(e), hasPassiveOption ? { passive: false } : false);
-            element.addEventListener("touchend", (e) => this.onTouchEnd(e), hasPassiveOption ? { passive: true } : false);
+            element.addEventListener("touchstart", (e) => this.onItemTouchStart(e), hasPassiveOption ? { passive: true } : false);
+            element.addEventListener("touchmove", (e) => this.onItemTouchMove(e), hasPassiveOption ? { passive: false } : false);
+            element.addEventListener("touchend", (e) => this.onItemTouchEnd(e), hasPassiveOption ? { passive: true } : false);
             element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false);
         });
 
-        let entryContentElement = document.querySelector(".entry-content");
-        if (entryContentElement && entryContentElement.classList.contains('double-tap')) {
-            let doubleTapTimers = {
-                previous: null,
-                next: null
-            };
-
-            const detectDoubleTap = (doubleTapTimer, event) => {
-                const timer = doubleTapTimers[doubleTapTimer];
-                if (timer === null) {
-                    doubleTapTimers[doubleTapTimer] = setTimeout(() => {
-                        doubleTapTimers[doubleTapTimer] = null;
-                    }, 200);
-                } else {
-                    event.preventDefault();
-                    goToPage(doubleTapTimer);
-                }
-            };
-
-            entryContentElement.addEventListener("touchend", (e) => {
-                if (e.changedTouches[0].clientX >= (entryContentElement.offsetWidth / 2)) {
-                    detectDoubleTap("next", e);
-                } else {
-                    detectDoubleTap("previous", e);
-                }
-            }, hasPassiveOption ? { passive: false } : false);
-
-            entryContentElement.addEventListener("touchmove", (e) => {
-                Object.keys(doubleTapTimers).forEach(timer => doubleTapTimers[timer] = null);
-            });
+        let element = document.querySelector(".entry-content");
+
+        if (element) {
+            if (element.classList.contains("gesture-nav-tap")) {
+                element.addEventListener("touchend", (e) => this.onTapEnd(e), hasPassiveOption ? { passive: true } : false);
+                element.addEventListener("touchmove", () => this.reset(), hasPassiveOption ? { passive: true } : false);
+                element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false);
+            } else if (element.classList.contains("gesture-nav-swipe")) {
+                element.addEventListener("touchstart", (e) => this.onContentTouchStart(e), hasPassiveOption ? { passive: true } : false);
+                element.addEventListener("touchmove", (e) => this.onContentTouchMove(e), hasPassiveOption ? { passive: true } : false);
+                element.addEventListener("touchend", (e) => this.onContentTouchEnd(e), hasPassiveOption ? { passive: true } : false);
+                element.addEventListener("touchcancel", () => this.reset(), hasPassiveOption ? { passive: true } : false);
+            }
         }
     }
 }

+ 13 - 0
validator/user.go

@@ -79,6 +79,12 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
 		}
 	}
 
+	if changes.GestureNav != nil {
+		if err := validateGestureNav(*changes.GestureNav); err != nil {
+			return err
+		}
+	}
+
 	if changes.DefaultReadingSpeed != nil {
 		if err := validateReadingSpeed(*changes.DefaultReadingSpeed); err != nil {
 			return err
@@ -163,6 +169,13 @@ func validateDisplayMode(displayMode string) *ValidationError {
 	return nil
 }
 
+func validateGestureNav(gestureNav string) *ValidationError {
+	if gestureNav != "none" && gestureNav != "tap" && gestureNav != "swipe" {
+		return NewValidationError("error.invalid_gesture_nav")
+	}
+	return nil
+}
+
 func validateDefaultHomePage(defaultHomePage string) *ValidationError {
 	defaultHomePages := model.HomePages()
 	if _, found := defaultHomePages[defaultHomePage]; !found {