Files
harbour-calibreweb/qml/pages/ReaderPage.qml
T
kaneda 7bb4aa845a Lettore 0.3.1: cambio capitolo affidabile con scrollBy
Il confronto con max calcolato (scrollHeight-innerHeight) falliva sul WebView
di Sailfish quando max è sovrastimato: scrollY non lo raggiungeva mai, ok
restava true e atEnd veniva ignorato -> si restava dentro il capitolo.
Ora: scrollBy(0,vh) + verifica 'lo scroll si è mosso'; se bloccato, unlock
CSS e riprova; se ancora fermo -> capitolo successivo (o precedente).
2026-08-21 08:16:25 +02:00

516 lines
20 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick 2.0
import Sailfish.Silica 1.0
import Sailfish.WebView 1.0
import harbour.calibreweb 1.0
Page {
id: page
property string epubPath: ""
property int bookId: 0
property string bookFormat: "epub"
property int currentChapter: 0
property double chapterFraction: 0.0
// posizione di lettura nel capitolo corrente, in caratteri di testo
// (conteggio JS sui text node: stabile al variare di font/margini)
property int charOffset: 0
// offset da ripristinare al load del capitolo (-1 = nessuno)
property int restoreCharOffset: -1
// ultimo offset assoluto salvato (evita scritture continue)
property int lastSavedAbs: -1
// pagina corrente / pagine totali del capitolo (per la label del menu)
property int pageIndex: 1
property int pageCount: 1
property bool ready: false
property bool restoring: false
property string errorMessage: ""
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
property double fontScale: 1.0
allowedOrientations: Orientation.All
EpubBook {
id: book
}
SilicaFlickable {
anchors.fill: parent
contentHeight: parent.height
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
PullDownMenu {
MenuItem {
text: qsTr("Table of contents")
onClicked: showChapterList()
}
MenuItem {
text: qsTr("Text margins")
onClicked: {
var dlg = pageStack.push(Qt.resolvedUrl("MarginDialog.qml"), {
marginLr: appSettings.readerMargin,
marginTb: appSettings.readerMarginV,
previewHandler: page.previewMargins
})
dlg.accepted.connect(function() {
// i valori sono già persistiti da onAccepted; rinfresca subito
applyMarginsJS()
})
}
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
MenuItem {
text: qsTr("Close book")
onClicked: {
page.maybeSave(true)
// differito: pop() subito dall'handler della pagina che viene
// distrutta crasha; pop(null) = torna alla prima pagina
closeBookTimer.start()
}
}
}
// controlli del lettore: un solo trascinamento in alto li apre
PushUpMenu {
id: readerMenu
Label {
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * Theme.horizontalPageMargin
text: (book.chapterCount > 0)
? page.pageIndex + "/" + page.pageCount + " · "
+ (page.currentChapter + 1) + "/" + book.chapterCount
+ " · " + book.chapterTitle(page.currentChapter)
+ " · " + Math.round(page.overallProgress() * 100) + "%"
: "…"
truncationMode: TruncationMode.Elide
color: Theme.primaryColor
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Theme.fontSizeSmall
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.paddingSmall
IconButton {
icon.source: "image://theme/icon-m-left"
enabled: page.currentChapter > 0
onClicked: gotoChapter(page.currentChapter - 1)
}
Button {
text: "A−"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(-0.15)
}
Button {
text: "A+"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(0.15)
}
IconButton {
icon.source: "image://theme/icon-m-right"
enabled: page.currentChapter < book.chapterCount - 1
onClicked: gotoChapter(page.currentChapter + 1)
}
}
ProgressBar {
width: parent.width
value: page.overallProgress()
minimumValue: 0
maximumValue: 1
}
}
WebView {
id: web
anchors.fill: parent
active: true
onLoadedChanged: {
if (!loaded)
return
loadWatchdog.stop()
page.ready = true
// sblocca lo scroll se il CSS del libro lo blocca, poi ripristina
// la posizione per OFFSET DI CARATTERE (stabile con font/margini)
web.runJavaScript(page.unlockScrollJs(), function() {
if (page.restoreCharOffset >= 0) {
var off = page.restoreCharOffset
page.restoreCharOffset = -1
if (off >= book.chapterTextLength(page.currentChapter)) {
// oltre la fine = vai all'ultima pagina (usato da
// prevPage per non saltare la coda del capitolo)
web.runJavaScript(
"window.scrollTo(0,Math.max(1,"
+ "document.documentElement.scrollHeight-window.innerHeight));true",
function() { readScrollPosition() })
} else {
web.runJavaScript(page.scrollToCharJs(off), function() {
readScrollPosition()
})
}
} else {
readScrollPosition()
}
})
page.restoring = false
}
}
// Paginazione a tap: niente scroll libero. La meta' destra va avanti di
// una pagina, la sinistra indietro. Le fasce superiore e inferiore sono
// lasciate ai menu (PullDown in alto, PushUp in basso).
MouseArea {
id: tapZone
anchors.fill: parent
anchors.topMargin: Theme.itemSizeLarge
anchors.bottomMargin: Theme.itemSizeLarge + Theme.paddingLarge
onClicked: {
if (mouse.x > width / 2)
page.nextPage()
else
page.prevPage()
}
}
}
BusyIndicator {
anchors.centerIn: parent
running: !page.ready
size: BusyIndicatorSize.Large
}
// watchdog: se il capitolo non si carica, niente spinner infinito
Timer {
id: loadWatchdog
interval: 15000
onTriggered: {
if (!web.loaded && !page.ready) {
page.errorMessage = qsTr("The reader is not responding (WebView). The EPUB file may be damaged.")
page.ready = true
}
}
}
// ritardo di apertura: evita la race tra onCompleted e l'init del WebView
Timer {
id: openTimer
interval: 100
onTriggered: openChapter()
}
// chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage)
Timer {
id: closeBookTimer
interval: 50
onTriggered: pageStack.pop(null)
}
Label {
anchors.centerIn: parent
text: page.errorMessage
color: Theme.errorColor
visible: page.errorMessage.length > 0
}
// ---- controlli spostati nel PushUpMenu (trascina in alto) ----
// ---- timer di tracciamento ----
Timer {
id: trackTimer
interval: 2000
running: page.ready
repeat: true
onTriggered: readScrollPosition()
}
function overallProgress() {
if (book.chapterCount <= 0)
return 0
return (page.currentChapter + page.chapterFraction) / book.chapterCount
}
// JS che sblocca lo scroll del documento: rimuove overflow hidden / altezze
// fisse che i CSS di alcuni EPUB impongono (sintomo: max==0, ogni tap salta
// al capitolo successivo saltando tutto il testo)
function unlockScrollJs() {
return "(function(){"
+ "var de=document.documentElement,b=document.body;"
+ "if(!de||!b)return false;"
+ "de.style.setProperty('overflow','visible','important');"
+ "b.style.setProperty('overflow','visible','important');"
+ "de.style.setProperty('height','auto','important');"
+ "b.style.setProperty('height','auto','important');"
+ "var after=Math.max(de.scrollHeight,b.scrollHeight);"
+ "if(after<=window.innerHeight+1&&b.textContent.length>0){"
+ "var all=b.querySelectorAll('div,section,article,main,td,li');"
+ "for(var i=0;i<all.length;i++){var cs=getComputedStyle(all[i]);"
+ "if(cs.overflowY==='hidden'||cs.overflowY==='scroll'){"
+ "all[i].style.setProperty('overflow','visible','important');}}}"
+ "return true;})()"
}
// JS che legge la posizione: {o: caratteri di testo sopra la viewport,
// f: frazione di scroll, p: pagina corrente, pc: pagine totali}.
// Il conteggio cammina i text node del DOM: stabile al variare di font e
// margini (a differenza della frazione di scroll).
function positionJs() {
return "(function(){"
+ "var de=document.documentElement,b=document.body;"
+ "var max=Math.max(1,Math.max(de.scrollHeight,b.scrollHeight)-window.innerHeight);"
+ "var y=Math.max(0,Math.min(max,window.scrollY));"
+ "var vh=window.innerHeight;"
+ "var o=0;"
+ "var it=document.createTreeWalker(b,NodeFilter.SHOW_TEXT);"
+ "var n;"
+ "while((n=it.nextNode())){"
+ "var p=n.parentNode;"
+ "if(!p||p.tagName==='SCRIPT'||p.tagName==='STYLE'||p.tagName==='NOSCRIPT')continue;"
+ "var r=document.createRange();r.selectNodeContents(n);"
+ "var rect=r.getBoundingClientRect();"
+ "if(rect.bottom<=0){o+=n.data.length;continue;}"
+ "if(rect.top>=0)break;"
+ "var h=rect.bottom-rect.top;"
+ "if(h>0)o+=Math.round(n.data.length*(0-rect.top)/h);"
+ "break;}"
+ "return JSON.stringify({o:o,f:y/max,p:Math.floor(y/vh)+1,"
+ "pc:Math.max(1,Math.floor(max/vh)+1)});})()"
}
// JS che porta in cima alla viewport il carattere 'off' del capitolo
function scrollToCharJs(off) {
return "(function(){"
+ "var b=document.body,o=0;"
+ "var it=document.createTreeWalker(b,NodeFilter.SHOW_TEXT);"
+ "var n;"
+ "while((n=it.nextNode())){"
+ "var p=n.parentNode;"
+ "if(!p||p.tagName==='SCRIPT'||p.tagName==='STYLE'||p.tagName==='NOSCRIPT')continue;"
+ "if(o+n.data.length>=" + off + "){"
+ "var r=document.createRange();r.setStart(n," + off + "-o);r.collapse(true);"
+ "var rect=r.getBoundingClientRect();"
+ "if(rect.top>0)window.scrollTo(0,rect.top+window.scrollY-8);"
+ "return true;}"
+ "o+=n.data.length;}"
+ "return false;})()"
}
function readScrollPosition() {
if (!web.loaded)
return
web.runJavaScript(page.positionJs(), function(res) {
var r = JSON.parse(res)
page.charOffset = r.o
page.chapterFraction = r.f
page.pageIndex = r.p
page.pageCount = r.pc
maybeSave(false)
})
}
function maybeSave(force) {
if (bookId <= 0)
return
// posizione assoluta nel libro = capitolo + caratteri del capitolo
var abs = book.chapterStartChar(page.currentChapter) + page.charOffset
// salva subito su forzatura, altrimenti solo se la posizione è cambiata
// di almeno 20 caratteri (evita scritture continue a tracker attivo)
if (force || Math.abs(abs - page.lastSavedAbs) > 20) {
page.lastSavedAbs = abs
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
book.makeBookmarkKey(page.currentChapter, page.charOffset))
}
}
function gotoChapter(index) {
if (index < 0 || index >= book.chapterCount)
return
if (index !== page.currentChapter) {
maybeSave(true)
page.chapterFraction = 0
page.charOffset = 0
page.restoreCharOffset = -1
page.lastSavedAbs = -1
}
page.currentChapter = index
loadChapter()
}
function changeFontScale(delta) {
page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta))
// ricarica con la nuova dimensione, mantenendo la posizione di lettura:
// l'offset in CARATTERI è stabile col reflow (a differenza dei px scrollati)
page.restoreCharOffset = page.charOffset
loadChapter()
}
// pagina successiva: una viewport avanti. Lo scroll avviene con scrollBy e
// la verifica è "lo scroll si è MOSSO?" — non un confronto con max calcolato
// (innerHeight/scrollHeight del WebView non sempre corrispondono allo scroll
// reale: con max sovrastimato scrollY non lo raggiungeva mai e il passaggio
// al capitolo successivo non scattava). Se non si muove: unlock CSS e riprova;
// se ancora fermo, siamo a fine capitolo (o capitolo non scrollabile) -> avanti.
function nextPage() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(
"(function(){"
+ "var de=document.documentElement,b=document.body;"
+ "var vh=window.innerHeight;"
+ "var before=window.scrollY;"
+ "window.scrollBy(0,vh);"
+ "if(window.scrollY>before+1)"
+ "return JSON.stringify({ok:true,atEnd:false});"
+ "de.style.setProperty('overflow','visible','important');"
+ "b.style.setProperty('overflow','visible','important');"
+ "de.style.setProperty('height','auto','important');"
+ "b.style.setProperty('height','auto','important');"
+ "window.scrollBy(0,vh);"
+ "if(window.scrollY>before+1)"
+ "return JSON.stringify({ok:true,atEnd:false});"
+ "return JSON.stringify({ok:false,atEnd:true});})()",
function(res) {
var r = JSON.parse(res)
if (r.ok) {
readScrollPosition()
} else if (r.atEnd) {
gotoChapter(page.currentChapter + 1)
}
})
}
// pagina precedente: una viewport indietro; se lo scroll non si muove siamo
// all'inizio -> capitolo prima SULL'ULTIMA PAGINA (non salta la coda)
function prevPage() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(
"(function(){"
+ "var vh=window.innerHeight;"
+ "var before=window.scrollY;"
+ "window.scrollBy(0,-vh);"
+ "if(window.scrollY<before-1)"
+ "return JSON.stringify({ok:true,atStart:false});"
+ "return JSON.stringify({ok:false,atStart:true});})()",
function(res) {
var r = JSON.parse(res)
if (r.ok) {
readScrollPosition()
} else if (r.atStart && page.currentChapter > 0) {
maybeSave(true)
page.currentChapter -= 1
page.chapterFraction = 0
page.charOffset = 0
// sentinella "fine capitolo": va oltre la lunghezza, così
// onLoadedChanged porta all'ultima pagina
page.restoreCharOffset = book.chapterTextLength(page.currentChapter) + 1
page.lastSavedAbs = -1
loadChapter()
}
})
}
function fontPx() {
return Math.round(16 * page.fontScale * 100) / 100
}
// carica il capitolo corrente via loadHtml: lo stile di reflow (font-size
// e margini) è iniettato nell'HTML dal parser, quindi niente JS post-load
function loadChapter() {
var html = book.chapterHtml(page.currentChapter, page.fontPx(),
appSettings.readerMargin, appSettings.readerMarginV)
if (html.length === 0) {
page.errorMessage = qsTr("Chapter unreadable")
page.ready = true
return
}
page.ready = false
page.restoring = true
loadWatchdog.restart()
web.loadHtml(html, book.chapterBaseUrl(page.currentChapter))
}
function showChapterList() {
var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"),
{ model: book.chapterList() })
dialog.accepted.connect(function() {
gotoChapter(dialog.selectedIndex)
})
}
// JS che sovrascrive i margini sul documento aperto: stile inline con
// 'important' batte lo stylesheet iniettato. Effetto immediato, niente reload.
function marginJs(lr, tb) {
return "var h=document.documentElement,b=document.body;"
+ "h.style.setProperty('margin-left','" + lr + "px','important');"
+ "h.style.setProperty('margin-right','" + lr + "px','important');"
+ "b.style.setProperty('margin-left','" + lr + "px','important');"
+ "b.style.setProperty('margin-right','" + lr + "px','important');"
+ "b.style.setProperty('margin-top','" + tb + "px','important');"
+ "b.style.setProperty('margin-bottom','" + tb + "px','important');"
+ "true"
}
// riapplica i margini salvati nelle impostazioni (senza ricaricare il libro)
function applyMarginsJS() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(marginJs(appSettings.readerMargin, appSettings.readerMarginV),
function() { })
}
// anteprima live dal dialogo margini: valori non ancora persistiti
function previewMargins(lr, tb) {
if (!web.loaded || !page.ready)
return
web.runJavaScript(marginJs(lr, tb), function() { })
}
Component.onCompleted: {
page.ready = false
if (!book.open(page.epubPath)) {
page.errorMessage = qsTr("Unable to open the EPUB file")
page.ready = true
return
}
openTimer.start()
}
function openChapter() {
// ripristina il segno locale: posizione per CARATTERE (v2),
// le vecchie chiavi v1 vengono migrate da parseBookmarkKey
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
var ch = 0
var off = -1
if (key.length > 0) {
var r = book.parseBookmarkKey(key)
if (r && r.hasOwnProperty("chapter")) {
ch = Math.max(0, Math.min(r.chapter, book.chapterCount - 1))
off = r.hasOwnProperty("charOffset") ? r.charOffset : -1
}
}
page.currentChapter = ch
page.chapterFraction = 0
page.charOffset = 0
page.restoreCharOffset = off
page.lastSavedAbs = -1
loadChapter()
}
onStatusChanged: {
if (status === PageStatus.Inactive) {
maybeSave(true)
} else if (status === PageStatus.Active) {
// riapplica i margini se cambiati nelle impostazioni:
// via JS sul documento aperto, senza ricaricare il capitolo
if (page.ready && book.chapterCount() > 0)
applyMarginsJS()
}
}
}