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
This commit is contained in:
Huang Xin
2025-02-25 13:07:44 +01:00
committed by GitHub
parent f8ea7fc463
commit 7be6c07344
6 changed files with 96 additions and 35 deletions
+4 -3
View File
@@ -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
```
@@ -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 = () => {
@@ -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');
}
}
};
@@ -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]);
+27 -17
View File
@@ -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<DialogProps> = ({
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<DialogProps> = ({
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<DialogProps> = ({
<dialog
id={id ?? 'dialog'}
open={isOpen}
className={clsx('modal sm:min-w-90 z-50 h-full w-full sm:w-full', className)}
className={clsx('modal sm:min-w-90 z-50 h-full w-full !bg-transparent sm:w-full', className)}
>
<div className='overlay fixed inset-0 z-10 bg-black/30 sm:bg-black/20' />
<div className='overlay fixed inset-0 z-10 bg-black/50 sm:bg-black/20' />
<div
className={clsx(
'modal-box settings-content z-20 flex flex-col rounded-none rounded-tl-2xl rounded-tr-2xl p-0 sm:rounded-2xl',
@@ -99,9 +112,6 @@ const Dialog: React.FC<DialogProps> = ({
appService?.hasSafeAreaInset && 'pt-[env(safe-area-inset-top)] sm:pt-0',
boxClassName,
)}
style={{
transform: `translateY(${translateY}px)`,
}}
>
{window.innerWidth < 640 && (
<div
+25 -7
View File
@@ -2,7 +2,14 @@ import { useCallback, useRef } from 'react';
export const useDrag = (
onDragMove: (data: { clientX: number; clientY: number; deltaX: number; deltaY: number }) => 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);