From 7be6c07344460fed6d84650e4489c527765cb05b Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 25 Feb 2025 13:07:44 +0100 Subject: [PATCH] ux: enhancements on iOS with modals and annotation tools (#447) * mobile: auto save progress also for iOS * ux: enhancements on pull-down to dismiss modals * doc: update reademe * ux: tricks to dismiss system selection tools on iOS --- README.md | 7 +-- .../reader/components/annotator/Annotator.tsx | 21 ++++++++- .../app/reader/components/sidebar/SideBar.tsx | 21 +++++++-- .../app/reader/hooks/useProgressAutoSave.ts | 6 +-- apps/readest-app/src/components/Dialog.tsx | 44 ++++++++++++------- apps/readest-app/src/hooks/useDrag.ts | 32 +++++++++++--- 6 files changed, 96 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 6ce574f5..8edea090 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,9 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion The Readest app is available for download! 🥳 🚀 - macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore]. -- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases]. +- Windows / Linux / Android: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases]. +- iOS: Available on TestFlight (send your Apple ID to readestapp@gmail.com to request access) - Web: Visit [https://web.readest.com][link-web-readest]. -- iOS / Android: coming soon 👀 ## Requirements @@ -149,8 +149,9 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi ### 4. Build for Development ```bash +# Start development for the Tauri app pnpm tauri dev -# or start development for the Web version only +# or start development for the Web app pnpm dev-web ``` diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 62231223..30e353d3 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -95,11 +95,26 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { } setSelection({ key: bookKey, text: sel.toString(), range, index }); }; + // FIXME: extremely hacky way to dismiss system selection tools on iOS + const makeSelectionOnIOS = (sel: Selection) => { + isTextSelected.current = true; + const range = sel.getRangeAt(0); + setTimeout(() => { + sel.removeAllRanges(); + setTimeout(() => { + if (!isTextSelected.current) return; + sel.addRange(range); + setSelection({ key: bookKey, text: range.toString(), range, index }); + }, 40); + }, 0); + }; const handleSelectionchange = () => { // Available on iOS, Android and Desktop, fired when the selection is changed // Ideally the popup only shows when the selection is done, // but on Android no proper events are fired to notify selection done or I didn't find it, // we make the popup show when the selection is changed + if (osPlatform === 'ios' || appService?.isIOSApp) return; + const sel = doc.getSelection(); if (isValidSelection(sel)) { if (osPlatform === 'android' && isTouchstarted.current) { @@ -118,7 +133,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { // Note that on Android, pointerup event is fired after an additional touch event const sel = doc.getSelection(); if (isValidSelection(sel)) { - makeSelection(sel, true); + if (osPlatform === 'ios' || appService?.isIOSApp) { + makeSelectionOnIOS(sel); + } else { + makeSelection(sel, true); + } } }; const handleTouchstart = () => { diff --git a/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx index c351414e..3eb79a82 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import React, { useEffect, useState } from 'react'; +import { impactFeedback } from '@tauri-apps/plugin-haptics'; import { useSettingsStore } from '@/store/settingsStore'; import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; @@ -21,6 +22,8 @@ import useShortcuts from '@/hooks/useShortcuts'; const MIN_SIDEBAR_WIDTH = 0.05; const MAX_SIDEBAR_WIDTH = 0.45; +const VELOCITY_THRESHOLD = 0.5; + const SideBar: React.FC<{ onGoToLibrary: () => void; }> = ({ onGoToLibrary }) => { @@ -95,23 +98,33 @@ const SideBar: React.FC<{ } }; - const handleVerticalDragEnd = (velocity: number) => { + const handleVerticalDragEnd = (data: { velocity: number; clientY: number }) => { const sidebar = document.querySelector('.sidebar-container') as HTMLElement; const overlay = document.querySelector('.overlay') as HTMLElement; if (!sidebar || !overlay) return; - if (velocity > 0.5) { - sidebar.style.transition = `top ${0.15 / velocity}s ease-out`; + if ( + data.velocity > VELOCITY_THRESHOLD || + (data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5) + ) { + const transitionDuration = 0.15 / Math.max(data.velocity, 0.5); + sidebar.style.transition = `top ${transitionDuration}s ease-out`; sidebar.style.top = '100%'; - overlay.style.transition = `opacity ${0.15 / velocity}s ease-out`; + overlay.style.transition = `opacity ${transitionDuration}s ease-out`; overlay.style.opacity = '0'; setTimeout(() => setSideBarVisible(false), 300); + if (appService?.hasHaptics) { + impactFeedback('medium'); + } } else { sidebar.style.transition = 'top 0.3s ease-out'; sidebar.style.top = '0%'; overlay.style.transition = 'opacity 0.3s ease-out'; overlay.style.opacity = '0.8'; + if (appService?.hasHaptics) { + impactFeedback('medium'); + } } }; diff --git a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts index 47188106..791d7591 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressAutoSave.ts @@ -22,9 +22,9 @@ export const useProgressAutoSave = (bookKey: string) => { ); useEffect(() => { - // FIXME: Save book config when progress changes on Android - // until we can hook into the lifecycle of the Android app - if (!appService?.isAndroidApp || !progress) return; + // FIXME: On Android and iOS we need a better way to be notified + // when the app is about to go in background + if (!appService?.isMobile || !progress) return; saveBookConfig(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [progress, bookKey]); diff --git a/apps/readest-app/src/components/Dialog.tsx b/apps/readest-app/src/components/Dialog.tsx index 39b8439e..01263275 100644 --- a/apps/readest-app/src/components/Dialog.tsx +++ b/apps/readest-app/src/components/Dialog.tsx @@ -1,12 +1,11 @@ import clsx from 'clsx'; -import React, { ReactNode, useEffect, useState } from 'react'; +import React, { ReactNode, useEffect } from 'react'; import { MdArrowBackIosNew } from 'react-icons/md'; import { useEnv } from '@/context/EnvContext'; import { useDrag } from '@/hooks/useDrag'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { impactFeedback } from '@tauri-apps/plugin-haptics'; -const DISMISS_THRESHOLD = 100; const VELOCITY_THRESHOLD = 0.5; interface DialogProps { @@ -33,7 +32,6 @@ const Dialog: React.FC = ({ onClose, }) => { const { appService } = useEnv(); - const [translateY, setTranslateY] = useState(0); const iconSize22 = useResponsiveSize(22); const isMobile = window.innerWidth < 640; @@ -56,27 +54,42 @@ const Dialog: React.FC = ({ const modal = document.querySelector('.modal-box') as HTMLElement; const overlay = document.querySelector('.overlay') as HTMLElement; + + const heightFraction = data.clientY / window.innerHeight; + const newTop = Math.max(0.0, Math.min(1, heightFraction)); + if (modal && overlay) { modal.style.transition = ''; - overlay.style.opacity = `${1 - data.clientY / window.innerHeight}`; + modal.style.transform = `translateY(${newTop * 100}%)`; + overlay.style.opacity = `${1 - heightFraction}`; } - setTranslateY((prev) => Math.max(prev + data.deltaY, 0)); }; - const handleDragEnd = (velocity: number) => { + const handleDragEnd = (data: { velocity: number; clientY: number }) => { const modal = document.querySelector('.modal-box') as HTMLElement; - if (!modal) return; + const overlay = document.querySelector('.overlay') as HTMLElement; + if (!modal || !overlay) return; - if (translateY > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) { - modal.style.transition = `transform ${0.15 / velocity}s ease-out`; - onClose(); - setTimeout(() => setTranslateY(0), 300); + if ( + data.velocity > VELOCITY_THRESHOLD || + (data.velocity >= 0 && data.clientY >= window.innerHeight * 0.5) + ) { + const transitionDuration = 0.15 / Math.max(data.velocity, 0.5); + modal.style.transition = `transform ${transitionDuration}s ease-out`; + modal.style.transform = 'translateY(100%)'; + overlay.style.transition = `opacity ${transitionDuration}s ease-out`; + overlay.style.opacity = '0'; + setTimeout(() => { + onClose(); + modal.style.transform = 'translateY(0%)'; + }, 300); if (appService?.hasHaptics) { impactFeedback('medium'); } } else { modal.style.transition = `transform 0.3s ease-out`; - setTranslateY(0); + modal.style.transform = `translateY(0%)`; + overlay.style.opacity = '0'; if (appService?.hasHaptics) { impactFeedback('medium'); } @@ -89,9 +102,9 @@ const Dialog: React.FC = ({ -
+
= ({ appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0', boxClassName, )} - style={{ - transform: `translateY(${translateY}px)`, - }} > {window.innerWidth < 640 && (
void, - onDragEnd?: (velocity: number) => void, + onDragEnd?: (data: { + velocity: number; + deltaT: number; + clientX: number; + clientY: number; + deltaX: number; + deltaY: number; + }) => void, ) => { const isDragging = useRef(false); const startX = useRef(0); @@ -52,17 +59,28 @@ export const useDrag = ( const handleEnd = (event: MouseEvent | TouchEvent) => { isDragging.current = false; + let deltaX = 0; + let deltaY = 0; + let clientX = 0; + let clientY = 0; const endTime = performance.now(); const deltaT = endTime - startTime.current; - const distanceY = - 'touches' in event - ? event.changedTouches[0]!.clientY - startY.current - : event.clientY - startY.current; - const velocity = distanceY / deltaT; + if ('touches' in event) { + const currentTouch = event.changedTouches[0]!; + clientX = currentTouch.clientX; + clientY = currentTouch.clientY; + } else { + const evt = event as MouseEvent; + clientX = evt.clientX; + clientY = evt.clientY; + } + deltaX = clientX - startX.current; + deltaY = clientY - startY.current; + const velocity = deltaY / deltaT; if (onDragEnd) { - onDragEnd(velocity); + onDragEnd({ velocity, deltaT, clientX, clientY, deltaX, deltaY }); } window.removeEventListener('mousemove', handleMove);