Files
harbour-calibreweb/qml/pages/ReaderPage.qml
T
kaneda a5088c3986 Lettore 0.3.4: tap avanti con scrollBy relativo (no window.scrollY)
scrollToCharJs calcolava scrollTo(0, rect.top+scrollY-8) e window.scrollY e'
stale sul WebView Sailfish: il tap avanti oscillava di pochi pixel. Ora
window.scrollBy(0, rect.top-8): nessuna lettura di scrollY, il carattere
target finisce esattamente in cima alla viewport.
2026-08-21 11:03:07 +02:00

535 lines
21 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
// caratteri di testo per pagina (stimati dalla viewport corrente): usato
// per il passo di pagina e per la label "pagina corrente/totale"
property int charsPerPage: 1
// 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):
// scrollTo oltre il limite porta in fondo al documento
web.runJavaScript(
"window.scrollTo(0,9999999);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 conta i caratteri di testo sopra la viewport (per il bookmark).
// Lo scroll NON è gestito qui: pagina/frazione si leggono dalle proprietà
// native del WebView (contentHeight/scrollPosition), sempre affidabili.
function positionJs() {
return "(function(){"
+ "var b=document.body;"
+ "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 o;})()"
}
// JS che conta i caratteri di testo NELLA viewport corrente (per il passo
// di pagina): stessa tecnica di positionJs, ma sulla fascia [0, innerHeight]
function charsInViewportJs() {
return "(function(){"
+ "var b=document.body;"
+ "var vh=window.innerHeight;"
+ "var c=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||rect.top>=vh)continue;"
+ "var top=Math.max(0,rect.top),bot=Math.min(vh,rect.bottom);"
+ "var h=rect.bottom-rect.top;"
+ "if(h>0)c+=Math.round(n.data.length*(bot-top)/h);"
+ "}"
+ "return c;})()"
}
// JS che porta in cima alla viewport il carattere 'off' del capitolo.
// Usa scrollBy RELATIVO (rect.top - 8): non legge mai window.scrollY,
// che sul WebView Sailfish non riflette lo scroll reale (con scrollTo +
// scrollY il tap oscillava di pochi pixel invece di avanzare).
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();"
+ "window.scrollBy(0,rect.top-8);"
+ "return true;}"
+ "o+=n.data.length;}"
+ "return false;})()"
}
function readScrollPosition() {
if (!web.loaded)
return
web.runJavaScript(page.positionJs(), function(o) {
page.charOffset = o || 0
// avanzamento per CARATTERI: stabile e indipendente dallo scroll reale
var total = book.chapterStartChar(book.chapterCount)
var start = book.chapterStartChar(page.currentChapter)
if (total > 0)
page.chapterFraction = (start + page.charOffset) / total
var len = book.chapterTextLength(page.currentChapter)
var pp = Math.max(1, page.charsPerPage)
page.pageIndex = Math.floor(page.charOffset / pp) + 1
page.pageCount = Math.max(1, Math.ceil(len / pp))
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: avanza di una "pagina di testo" (i caratteri visibili
// nella viewport corrente). La fine del capitolo è determinata dal CONFRONTO
// CON LA LUNGHEZZA IN CARATTERI (nota in C++), non dallo scroll reale del
// WebView — quindi il passaggio al capitolo successivo è sempre affidabile.
function nextPage() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(page.positionJs(), function(o) {
page.charOffset = o || 0
web.runJavaScript(page.charsInViewportJs(), function(c) {
page.charsPerPage = Math.max(1, Math.round(c || 1))
var target = page.charOffset + page.charsPerPage
if (target >= book.chapterTextLength(page.currentChapter)) {
// fine capitolo: segna la coda come letta e passa avanti
page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1)
maybeSave(true)
gotoChapter(page.currentChapter + 1)
} else {
web.runJavaScript(page.scrollToCharJs(target), function() {
readScrollPosition()
})
}
})
})
}
// pagina precedente: torna indietro di una pagina di testo; dall'inizio del
// capitolo va al capitolo prima SULL'ULTIMA PAGINA (non salta la coda)
function prevPage() {
if (!web.loaded || !page.ready)
return
web.runJavaScript(page.positionJs(), function(o) {
page.charOffset = o || 0
web.runJavaScript(page.charsInViewportJs(), function(c) {
page.charsPerPage = Math.max(1, Math.round(c || 1))
var target = page.charOffset - page.charsPerPage
if (target <= 0) {
if (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()
}
} else {
web.runJavaScript(page.scrollToCharJs(target), function() {
readScrollPosition()
})
}
})
})
}
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()
}
}
}