diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 6967e9048..03d2d186a 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -255,6 +255,7 @@ let currentCustomFontFaceCSS = ''; let currentCustomFontFamily = ''; let currentUseBookFonts = true; + let currentSmoothReading = false; let tapNavigationInFlight = false; let tapNavigationResetTimer = null; let readerElement = null; @@ -380,6 +381,21 @@ } } + // Smooth reading ("平滑阅读"): one toggle drives both the foliate + // `animated` page-turn transition and the `scroll-inertia` finger-flick + // physics. The attributes are inert until the renderer handles them, so + // this is safe to toggle at any time (load, viewMode change, or live). + function syncSmoothReadingAttribute(renderer) { + if (!renderer) return; + if (currentSmoothReading) { + renderer.setAttribute('animated', ''); + renderer.setAttribute('scroll-inertia', ''); + } else { + renderer.removeAttribute('animated'); + renderer.removeAttribute('scroll-inertia'); + } + } + function getTransformViewportSize() { const root = document.documentElement; const viewport = window.visualViewport; @@ -1859,6 +1875,10 @@ if (settings.justifyBodyText !== undefined) { currentJustifyBodyText = settings.justifyBodyText !== false; } + if (settings.smoothReading !== undefined) { + currentSmoothReading = settings.smoothReading === true; + syncSmoothReadingAttribute(renderer); + } // PDF pages are fixed-layout canvases. Reader font, line-height, custom // font and paragraph-spacing rules would move the transparent text layer // away from the canvas and render a second, misaligned copy of the text. @@ -5025,8 +5045,8 @@ diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html index d3658a0b3..32decaccf 100644 --- a/packages/app-expo/assets/reader/reader.template.html +++ b/packages/app-expo/assets/reader/reader.template.html @@ -208,6 +208,7 @@ let currentCustomFontFaceCSS = ''; let currentCustomFontFamily = ''; let currentUseBookFonts = true; + let currentSmoothReading = false; let tapNavigationInFlight = false; let tapNavigationResetTimer = null; let readerElement = null; @@ -333,6 +334,21 @@ } } + // Smooth reading ("平滑阅读"): one toggle drives both the foliate + // `animated` page-turn transition and the `scroll-inertia` finger-flick + // physics. The attributes are inert until the renderer handles them, so + // this is safe to toggle at any time (load, viewMode change, or live). + function syncSmoothReadingAttribute(renderer) { + if (!renderer) return; + if (currentSmoothReading) { + renderer.setAttribute('animated', ''); + renderer.setAttribute('scroll-inertia', ''); + } else { + renderer.removeAttribute('animated'); + renderer.removeAttribute('scroll-inertia'); + } + } + function getTransformViewportSize() { const root = document.documentElement; const viewport = window.visualViewport; @@ -1812,6 +1828,10 @@ if (settings.justifyBodyText !== undefined) { currentJustifyBodyText = settings.justifyBodyText !== false; } + if (settings.smoothReading !== undefined) { + currentSmoothReading = settings.smoothReading === true; + syncSmoothReadingAttribute(renderer); + } // PDF pages are fixed-layout canvases. Reader font, line-height, custom // font and paragraph-spacing rules would move the transparent text layer // away from the canvas and render a second, misaligned copy of the text. diff --git a/packages/app-expo/src/hooks/use-reader-bridge.ts b/packages/app-expo/src/hooks/use-reader-bridge.ts index ba9e1f8c0..07bed2b88 100644 --- a/packages/app-expo/src/hooks/use-reader-bridge.ts +++ b/packages/app-expo/src/hooks/use-reader-bridge.ts @@ -55,6 +55,8 @@ export interface ReaderInitialSettings { useBookFonts?: boolean; viewMode?: "paginated" | "scroll"; paginatedLayout?: "single" | "double"; + /** Smooth reading: foliate `animated` page-turn + `scroll-inertia` physics */ + smoothReading?: boolean; } function withJustifiedTextSetting(settings: ReaderInitialSettings = {}): ReaderInitialSettings { @@ -286,6 +288,7 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) { paginatedLayout?: "single" | "double"; customFontFaceCSS?: string; customFontFamily?: string; + smoothReading?: boolean; }) => { const msg = JSON.stringify({ type: "applySettings", diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx index 3eb75832a..a87fe5edc 100644 --- a/packages/app-expo/src/screens/ReaderScreen.tsx +++ b/packages/app-expo/src/screens/ReaderScreen.tsx @@ -595,6 +595,7 @@ export function ReaderScreen({ route, navigation }: Props) { useBookFonts: settings.useBookFonts, viewMode: settings.viewMode, paginatedLayout: settings.paginatedLayout, + smoothReading: settings.smoothReading === true, customFontFaceCSS: fontCSS, customFontFamily: fontFamily ?? "", }); @@ -1144,6 +1145,7 @@ export function ReaderScreen({ route, navigation }: Props) { useBookFonts: readSettings.useBookFonts, viewMode: readSettings.viewMode, paginatedLayout: readSettings.paginatedLayout, + smoothReading: readSettings.smoothReading === true, }, }); diff --git a/packages/app-expo/src/screens/reader/ReaderSettingsPanel.tsx b/packages/app-expo/src/screens/reader/ReaderSettingsPanel.tsx index 110484add..f3ae11e95 100644 --- a/packages/app-expo/src/screens/reader/ReaderSettingsPanel.tsx +++ b/packages/app-expo/src/screens/reader/ReaderSettingsPanel.tsx @@ -40,6 +40,7 @@ export function ReaderSettingsPanel({ visible, readSettings, bookId, onClose, on pageMargin: settingPageMargin, viewMode: settingViewMode, volumeButtonsPageTurn, + smoothReading, showTopTitleProgress, showBottomTimeBattery, followSystemFontScale, @@ -261,6 +262,23 @@ export function ReaderSettingsPanel({ visible, readSettings, bookId, onClose, on + {/* Smooth reading: animated page turns + scroll inertia */} + + + {t("settings.smoothReading")} + + {t("settings.smoothReadingDesc", "开启翻页过渡动画与滚动惯性,关闭可提升低性能设备流畅度")} + + + onUpdateSetting("smoothReading", !smoothReading)} + > + + {smoothReading ? t("settings.enabled") : t("settings.disabled")} + + + {Platform.OS === "android" && ( diff --git a/packages/app-expo/src/stores/settings-store.ts b/packages/app-expo/src/stores/settings-store.ts index 6c0876618..c72e37371 100644 --- a/packages/app-expo/src/stores/settings-store.ts +++ b/packages/app-expo/src/stores/settings-store.ts @@ -55,6 +55,7 @@ const defaultReadSettings: ReadSettings = { showTopTitleProgress: true, showBottomTimeBattery: true, volumeButtonsPageTurn: false, + smoothReading: false, defaultHighlightColor: "yellow", }; diff --git a/packages/core/src/i18n/locales/en/settings.json b/packages/core/src/i18n/locales/en/settings.json index 41f7038bc..7c62a68bc 100644 --- a/packages/core/src/i18n/locales/en/settings.json +++ b/packages/core/src/i18n/locales/en/settings.json @@ -72,6 +72,8 @@ "showBottomTimeBattery": "Bottom Time & Battery", "volumeButtonsPageTurn": "Volume Buttons Turn Pages", "volumeButtonsPageTurnDesc": "While reading, volume keys turn pages", + "smoothReading": "Smooth Reading", + "smoothReadingDesc": "Enable page-turn transition animation and scroll inertia. Disable for better performance on low-end devices", "followSystemFontScale": "Follow System Font Scale", "followSystemFontScaleDesc": "Auto-scale by the OS accessibility font size", "fixedLayoutNotice": "PDF is a fixed layout format. Font and layout settings are not available.", diff --git a/packages/core/src/i18n/locales/es/settings.json b/packages/core/src/i18n/locales/es/settings.json index 54ec854e6..1169f171d 100644 --- a/packages/core/src/i18n/locales/es/settings.json +++ b/packages/core/src/i18n/locales/es/settings.json @@ -72,6 +72,8 @@ "showBottomTimeBattery": "Hora y batería inferior", "volumeButtonsPageTurn": "Botones de volumen cambian página", "volumeButtonsPageTurnDesc": "Durante la lectura, los botones de volumen pasan página", + "smoothReading": "Lectura fluida", + "smoothReadingDesc": "Activa la animación de transición de página y la inercia de desplazamiento. Desactívelo para mejor rendimiento en dispositivos básicos", "followSystemFontScale": "Seguir escala de fuente del sistema", "followSystemFontScaleDesc": "Escalar automáticamente según el tamaño de fuente de accesibilidad del SO", "fixedLayoutNotice": "PDF es un formato de diseño fijo. Los ajustes de fuente y diseño no están disponibles.", diff --git a/packages/core/src/i18n/locales/fr/settings.json b/packages/core/src/i18n/locales/fr/settings.json index b3628bc5e..64bcde715 100644 --- a/packages/core/src/i18n/locales/fr/settings.json +++ b/packages/core/src/i18n/locales/fr/settings.json @@ -72,6 +72,8 @@ "showBottomTimeBattery": "Heure et batterie en bas", "volumeButtonsPageTurn": "Tourner les pages avec les boutons de volume", "volumeButtonsPageTurnDesc": "En lecture, les boutons de volume tournent les pages", + "smoothReading": "Lecture fluide", + "smoothReadingDesc": "Active l'animation de transition de page et l'inertie de défilement. Désactivez pour de meilleures performances sur les appareils modestes", "followSystemFontScale": "Suivre l'échelle de police système", "followSystemFontScaleDesc": "Mise à l'échelle auto selon la taille de police d'accessibilité du système", "fixedLayoutNotice": "Le PDF est un format à mise en page fixe. Les paramètres de police et mise en page ne sont pas disponibles.", diff --git a/packages/core/src/i18n/locales/ja/settings.json b/packages/core/src/i18n/locales/ja/settings.json index 7e9b7cdbc..c89c5832a 100644 --- a/packages/core/src/i18n/locales/ja/settings.json +++ b/packages/core/src/i18n/locales/ja/settings.json @@ -72,6 +72,8 @@ "showBottomTimeBattery": "下部時刻&バッテリー表示", "volumeButtonsPageTurn": "音量ボタンでページ送り", "volumeButtonsPageTurnDesc": "読書中は音量ボタンでページ送りします", + "smoothReading": "スムーズリーディング", + "smoothReadingDesc": "ページめくりアニメーションとスクロール慣性を有効化。低スペック端末ではオフにすると快適です", "followSystemFontScale": "システムフォントサイズに従う", "followSystemFontScaleDesc": "OSのアクセシビリティフォントサイズで自動調整", "fixedLayoutNotice": "PDFは固定レイアウト形式です。フォントとレイアウト設定は利用できません。", diff --git a/packages/core/src/i18n/locales/ko/settings.json b/packages/core/src/i18n/locales/ko/settings.json index 6870a78e3..18b715456 100644 --- a/packages/core/src/i18n/locales/ko/settings.json +++ b/packages/core/src/i18n/locales/ko/settings.json @@ -72,6 +72,8 @@ "showBottomTimeBattery": "하단 시간 및 배터리", "volumeButtonsPageTurn": "볼륨 버튼으로 페이지 넘기기", "volumeButtonsPageTurnDesc": "독서 중 볼륨 버튼으로 페이지를 넘깁니다", + "smoothReading": "부드러운 읽기", + "smoothReadingDesc": "페이지 넘김 전환 애니메이션과 스크롤 관성을 켭니다. 저사양 기기에서는 끄면 더 부드럽습니다", "followSystemFontScale": "시스템 글꼴 크기 따르기", "followSystemFontScaleDesc": "OS 접근성 글꼴 크기에 따라 자동 조정", "fixedLayoutNotice": "PDF는 고정 레이아웃 형식이에요. 글꼴 및 레이아웃 설정을 사용할 수 없어요.", diff --git a/packages/core/src/i18n/locales/zh-TW/settings.json b/packages/core/src/i18n/locales/zh-TW/settings.json index 3b1128ead..1cb61a0ad 100644 --- a/packages/core/src/i18n/locales/zh-TW/settings.json +++ b/packages/core/src/i18n/locales/zh-TW/settings.json @@ -72,6 +72,8 @@ "showBottomTimeBattery": "底部時間與電量", "volumeButtonsPageTurn": "音量鍵翻頁", "volumeButtonsPageTurnDesc": "開啟後閱讀時音量鍵用於上下翻頁", + "smoothReading": "平滑閱讀", + "smoothReadingDesc": "開啟翻頁過渡動畫與滾動慣性,關閉可提升低效能裝置流暢度", "followSystemFontScale": "跟隨系統字型大小", "followSystemFontScaleDesc": "按系統輔助功能字型大小自動放大", "fixedLayoutNotice": "PDF 為固定排版格式,字型和排版設定不可用。", diff --git a/packages/core/src/i18n/locales/zh/settings.json b/packages/core/src/i18n/locales/zh/settings.json index d0ce559c7..508744bc6 100644 --- a/packages/core/src/i18n/locales/zh/settings.json +++ b/packages/core/src/i18n/locales/zh/settings.json @@ -72,6 +72,8 @@ "showBottomTimeBattery": "底部时间与电量", "volumeButtonsPageTurn": "音量键翻页", "volumeButtonsPageTurnDesc": "开启后阅读时音量键用于上下翻页", + "smoothReading": "平滑阅读", + "smoothReadingDesc": "开启翻页过渡动画与滚动惯性,关闭可提升低性能设备流畅度", "followSystemFontScale": "跟随系统字号", "followSystemFontScaleDesc": "按系统辅助功能字号自动放大", "fixedLayoutNotice": "PDF 为固定布局格式,字体和排版设置不可用。", diff --git a/packages/core/src/stores/settings-store.ts b/packages/core/src/stores/settings-store.ts index 444d4ebfe..c9e0bc00b 100644 --- a/packages/core/src/stores/settings-store.ts +++ b/packages/core/src/stores/settings-store.ts @@ -57,6 +57,7 @@ const defaultReadSettings: ReadSettings = { showTopTitleProgress: true, showBottomTimeBattery: true, volumeButtonsPageTurn: false, + smoothReading: false, defaultHighlightColor: "yellow", followSystemFontScale: false, }; diff --git a/packages/core/src/types/book.ts b/packages/core/src/types/book.ts index 2415de8f3..f20703d71 100644 --- a/packages/core/src/types/book.ts +++ b/packages/core/src/types/book.ts @@ -97,6 +97,14 @@ export interface ReadSettings extends ViewSettings { showBottomTimeBattery: boolean; volumeButtonsPageTurn: boolean; defaultHighlightColor?: HighlightColor; + /** + * Mobile-only opt-in: smooth reading — enables the foliate `animated` + * page-turn transition and the `scroll-inertia` finger-flick physics. + * Default false so existing users keep the current (performance-first) + * rendering path. Optional so existing persisted settings deserialize + * cleanly. + */ + smoothReading?: boolean; /** * Mobile-only opt-in: when true, the reader scales fontSize by the OS * accessibility font scale (PixelRatio.getFontScale()) before rendering. diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js index ffe4d7235..d8b48eb38 100644 --- a/packages/foliate-js/paginator.js +++ b/packages/foliate-js/paginator.js @@ -950,7 +950,7 @@ export class Paginator extends HTMLElement { static observedAttributes = [ 'flow', 'gap', 'margin', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right', 'max-inline-size', 'max-block-size', 'max-column-count', - 'no-preload', 'no-background', 'no-continuous-scroll', + 'no-preload', 'no-background', 'no-continuous-scroll', 'scroll-inertia', ] #root = this.attachShadow({ mode: 'closed' }) #observer = new ResizeObserver(() => this.render()) @@ -976,6 +976,11 @@ export class Paginator extends HTMLElement { #scrollBounds #touchState #touchScrolled + #scrollInertiaFrame = null + #scrollInertiaToken = 0 + #visibilityHandler = () => { + if (document.hidden) this.#cancelScrollInertia() + } #lastVisibleRange #scrollLocked = false #isAnimating = false @@ -1155,6 +1160,7 @@ export class Paginator extends HTMLElement { this.#footer = this.#root.getElementById('footer') this.#observer.observe(this.#container) + document.addEventListener('visibilitychange', this.#visibilityHandler) const debouncedScroll = debounce(() => { if (this.scrolled && !this.#isAnimating) { // Skip entirely while stabilizing — preserve #justAnchored @@ -1231,10 +1237,12 @@ export class Paginator extends HTMLElement { this.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) this.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) this.addEventListener('touchend', this.#onTouchEnd.bind(this)) + this.addEventListener('touchcancel', this.#onTouchCancel.bind(this)) this.addEventListener('load', ({ detail: { doc } }) => { doc.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) doc.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) doc.addEventListener('touchend', this.#onTouchEnd.bind(this)) + doc.addEventListener('touchcancel', this.#onTouchCancel.bind(this)) }) this.addEventListener('relocate', ({ detail }) => { @@ -1622,6 +1630,7 @@ export class Paginator extends HTMLElement { return this.#primaryIndex } setAttribute(name, value) { + if (name === 'flow') this.#cancelScrollInertia() // The scrolled-mode scroll handler is debounced, so #anchor and // #primaryIndex can lag behind the user's actual viewport by up to // ~250ms. Toggling out of scrolled mode within that window made @@ -1676,6 +1685,9 @@ export class Paginator extends HTMLElement { } } break + case 'scroll-inertia': + if (value === null) this.#cancelScrollInertia() + break } } open(book) { @@ -2004,6 +2016,7 @@ export class Paginator extends HTMLElement { } set navigationLocked(value) { this.#navigationLocked = !!value + if (this.#navigationLocked) this.#cancelScrollInertia() } get noPreload() { return this.hasAttribute('no-preload') @@ -2129,6 +2142,7 @@ export class Paginator extends HTMLElement { this.#scrollToPage(page, 'snap') } #onTouchStart(e) { + this.#cancelScrollInertia() if (this.#navigationLocked) return const contents = this.getContents?.() ?? [] for (const { doc } of contents) { @@ -2142,6 +2156,8 @@ export class Paginator extends HTMLElement { vx: 0, xy: 0, dx: 0, dy: 0, dt: 0, + scrollVelocity: 0, + scrollSamples: [], startX: touch?.screenX, startY: touch?.screenY, didPreventDefault: false, @@ -2173,6 +2189,7 @@ export class Paginator extends HTMLElement { const dx = state.x - x const dy = state.y - y + const dt = Math.max(1, e.timeStamp - state.t) state.x = x state.y = y state.t = e.timeStamp @@ -2180,6 +2197,10 @@ export class Paginator extends HTMLElement { const delta = this.#vertical ? -dx : dy if (Math.abs(delta) < 0.5) return + state.scrollVelocity = delta / dt + state.scrollSamples.push({ velocity: state.scrollVelocity, time: e.timeStamp }) + if (state.scrollSamples.length > 5) state.scrollSamples.shift() + e.preventDefault() this.#touchScrolled = true @@ -2261,7 +2282,11 @@ export class Paginator extends HTMLElement { if (!this.#touchScrolled) return this.#touchScrolled = false - if (this.scrolled || this.#navigationLocked) return + if (this.scrolled) { + if (!this.#navigationLocked) this.#startScrollInertia(this.#touchState) + return + } + if (this.#navigationLocked) return if (this.hasAttribute('no-swipe')) return // XXX: Firefox seems to report scale as 1... sometimes...? @@ -2274,6 +2299,66 @@ export class Paginator extends HTMLElement { } }) } + #onTouchCancel() { + this.#cancelScrollInertia() + this.#touchState = null + this.#touchScrolled = false + } + #cancelScrollInertia() { + this.#scrollInertiaToken += 1 + if (this.#scrollInertiaFrame !== null) { + cancelAnimationFrame(this.#scrollInertiaFrame) + this.#scrollInertiaFrame = null + } + } + #startScrollInertia(state) { + if (!this.scrolled || !this.hasAttribute('scroll-inertia') || document.hidden) return + const samples = state?.scrollSamples || [] + if (!samples.length) return + const recent = samples.slice(-3) + const velocity = recent.reduce((sum, sample) => sum + sample.velocity, 0) / recent.length + if (!Number.isFinite(velocity) || Math.abs(velocity) < 0.02) return + + this.#cancelScrollInertia() + const token = this.#scrollInertiaToken + const startedAt = performance.now() + let previousTime = startedAt + let currentVelocity = velocity + const frictionTau = 325 + const minVelocity = 0.015 + const maxDuration = 900 + + const finish = () => { + if (token !== this.#scrollInertiaToken) return + this.#scrollInertiaFrame = null + this.#afterScroll('scroll') + } + const step = now => { + if (token !== this.#scrollInertiaToken) return + if (document.hidden || !this.scrolled) return finish() + const elapsed = now - startedAt + const dt = Math.min(32, Math.max(1, now - previousTime)) + previousTime = now + currentVelocity *= Math.exp(-dt / frictionTau) + if (elapsed >= maxDuration || Math.abs(currentVelocity) < minVelocity) return finish() + + const delta = currentVelocity * dt + const before = this.containerPosition + if (this.#vertical) this.scrollBy(0, delta) + else this.scrollBy(delta, 0) + const moved = Math.abs(this.containerPosition - before) + if (moved < 0.01) { + const forward = this.#vertical ? delta < 0 : delta > 0 + if (Math.abs(delta) > 2) { + if (forward && !this.atEnd) void this.next() + else if (!forward && !this.atStart) void this.prev() + } + return finish() + } + this.#scrollInertiaFrame = requestAnimationFrame(step) + } + this.#scrollInertiaFrame = requestAnimationFrame(step) + } // allows one to process rects as if they were LTR and horizontal #getRectMapper(view) { if (this.scrolled) { @@ -2320,6 +2405,7 @@ export class Paginator extends HTMLElement { return this.#scrollToPage(page, reason) } async #scrollTo(offset, reason, smooth) { + this.#cancelScrollInertia() const { size } = this if (this.containerPosition === offset) { this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] @@ -2904,6 +2990,7 @@ export class Paginator extends HTMLElement { } } async goTo(target) { + this.#cancelScrollInertia() if (this.#locked) return const resolved = await target if (this.#canGoToIndex(resolved.index)) return this.#goTo(resolved) @@ -2953,6 +3040,7 @@ export class Paginator extends HTMLElement { if (this.sections[index]?.linear !== 'no') return index } async #turnPage(dir, distance) { + this.#cancelScrollInertia() if (this.#locked) return this.#locked = true const prev = dir === -1 @@ -3046,7 +3134,9 @@ export class Paginator extends HTMLElement { this.#primaryView?.destroyLoupe() } destroy() { + this.#cancelScrollInertia() this.#observer.unobserve(this) + document.removeEventListener('visibilitychange', this.#visibilityHandler) this.#destroyAllViews() this.#mediaQuery.removeEventListener('change', this.#mediaQueryListener) }