window.scrollY/scrollBy via runJavaScript non riflette lo scroll reale del WebView Sailfish (la posizione non arrivava mai a fine documento) -> il cambio capitolo non scattava. Ora contentHeight/scrollPosition (proprietà native): nextPage/prevPage = aritmetica QML pura, cambio capitolo deterministico.
489 lines
19 KiB
QML
489 lines
19 KiB
QML
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.scrollPosition = Qt.point(0, Math.max(1, web.contentHeight - web.height))
|
||
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 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
|
||
// posizione letta dalle proprietà NATIVE del WebView (affidabili),
|
||
// il JS serve solo a contare i caratteri per il bookmark
|
||
var vh = Math.max(1, web.height)
|
||
var max = Math.max(1, web.contentHeight - vh)
|
||
var y = Math.max(0, Math.min(max, web.scrollPosition.y))
|
||
page.chapterFraction = y / max
|
||
page.pageIndex = Math.floor(y / vh) + 1
|
||
page.pageCount = Math.max(1, Math.floor(max / vh) + 1)
|
||
web.runJavaScript(page.positionJs(), function(o) {
|
||
page.charOffset = o || 0
|
||
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, usando le proprietà native del
|
||
// WebView (contentHeight/scrollPosition) — niente JS di navigazione, quindi
|
||
// nessuna dipendenza da runJavaScript/scrollY per il cambio capitolo.
|
||
// A fine capitolo (y >= max-2) passa al capitolo successivo.
|
||
function nextPage() {
|
||
if (!web.loaded || !page.ready)
|
||
return
|
||
var vh = Math.max(1, web.height)
|
||
var max = Math.max(1, web.contentHeight - vh)
|
||
var y = web.scrollPosition.y
|
||
if (y >= max - 2) {
|
||
// fine capitolo (o capitolo non scrollabile) -> avanti
|
||
gotoChapter(page.currentChapter + 1)
|
||
return
|
||
}
|
||
web.scrollPosition = Qt.point(0, Math.min(max, y + vh))
|
||
readScrollPosition()
|
||
}
|
||
|
||
// pagina precedente: una viewport indietro; all'inizio -> capitolo prima
|
||
// SULL'ULTIMA PAGINA (non salta la coda del capitolo)
|
||
function prevPage() {
|
||
if (!web.loaded || !page.ready)
|
||
return
|
||
var vh = Math.max(1, web.height)
|
||
var y = web.scrollPosition.y
|
||
if (y > 1) {
|
||
web.scrollPosition = Qt.point(0, Math.max(0, y - vh))
|
||
readScrollPosition()
|
||
} else 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()
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
}
|