Il WebView di Sailfish non supporta lo scroll programmatico affidabile (window.scrollY stale, contentHeight/scrollPosition non scrivibili): 0.3.x non ha mai cambiato capitolo in modo affidabile. Ora i capitoli sono rich text Qt in un Text QML dentro un SilicaFlickable: contentY/contentHeight nativi, paginazione a tap deterministico, fine capitolo esatta. HTML->rich text in C++ (tag base, entità, fallback). Bookmark v2 invariato (offset caratteri stimato da frazione). Niente immagini inline (limite rich text).
412 lines
15 KiB
QML
412 lines
15 KiB
QML
import QtQuick 2.0
|
||
import Sailfish.Silica 1.0
|
||
import harbour.calibreweb 1.0
|
||
|
||
// Lettore senza WebView: il capitolo è renderizzato come rich text Qt in un
|
||
// Text QML dentro un SilicaFlickable. Lo scroll (contentY/contentHeight) è
|
||
// nativo QML e sempre affidabile — sul WebView di Sailfish lo scroll
|
||
// programmatico e la lettura della posizione non funzionano (window.scrollY
|
||
// stale, contentHeight/scrollPosition non scrivibili): con il WebView il
|
||
// cambio capitolo non scattava mai.
|
||
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, stimata in caratteri di testo
|
||
// dalla frazione di scroll (stabile col reflow del font)
|
||
property int charOffset: 0
|
||
// offset da ripristinare al cambio 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 string errorMessage: ""
|
||
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
|
||
property double fontScale: 1.0
|
||
// restore in sospeso: frazione da applicare quando il testo si ri-impagina
|
||
property bool pendingRestore: false
|
||
property double restoreFrac: 0.0
|
||
// anteprima margini dal dialog (valori temporanei, -1 = usa le impostazioni)
|
||
property int previewMarginL: -1
|
||
property int previewMarginT: -1
|
||
|
||
allowedOrientations: Orientation.All
|
||
|
||
EpubBook {
|
||
id: book
|
||
}
|
||
|
||
SilicaFlickable {
|
||
id: readerFlick
|
||
anchors.fill: parent
|
||
contentHeight: readerContent.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; esci dal preview
|
||
page.previewMarginL = -1
|
||
page.previewMarginT = -1
|
||
})
|
||
}
|
||
}
|
||
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
|
||
}
|
||
}
|
||
|
||
// contenuto del capitolo: rich text Qt in un Text QML. Il cambio di
|
||
// currentChapter aggiorna il binding; il reflow (font/margini) è nativo.
|
||
Item {
|
||
id: readerContent
|
||
width: parent.width
|
||
height: Math.max(parent.height,
|
||
textItem.height + 2 * (page.previewMarginT >= 0
|
||
? page.previewMarginT
|
||
: appSettings.readerMarginV))
|
||
|
||
Text {
|
||
id: textItem
|
||
x: page.previewMarginL >= 0 ? page.previewMarginL : appSettings.readerMargin
|
||
y: page.previewMarginT >= 0 ? page.previewMarginT : appSettings.readerMarginV
|
||
width: parent.width - 2 * (page.previewMarginL >= 0
|
||
? page.previewMarginL
|
||
: appSettings.readerMargin)
|
||
text: book.chapterRichText(page.currentChapter)
|
||
textFormat: Text.RichText
|
||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||
font.pixelSize: page.fontPx()
|
||
color: Theme.primaryColor
|
||
onContentHeightChanged: page.onTextReflowed()
|
||
}
|
||
}
|
||
|
||
// 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).
|
||
// preventStealing: i drag nella zona centrale NON scrollano (paginazione
|
||
// a tap come richiesto), i pulley menu restano raggiungibili dai bordi.
|
||
MouseArea {
|
||
id: tapZone
|
||
anchors.fill: parent
|
||
anchors.topMargin: Theme.itemSizeLarge
|
||
anchors.bottomMargin: Theme.itemSizeLarge + Theme.paddingLarge
|
||
preventStealing: true
|
||
onClicked: {
|
||
if (mouse.x > width / 2)
|
||
page.nextPage()
|
||
else
|
||
page.prevPage()
|
||
}
|
||
}
|
||
}
|
||
|
||
Label {
|
||
anchors.centerIn: parent
|
||
text: page.errorMessage
|
||
color: Theme.errorColor
|
||
visible: page.errorMessage.length > 0
|
||
}
|
||
|
||
// chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage)
|
||
Timer {
|
||
id: closeBookTimer
|
||
interval: 50
|
||
onTriggered: pageStack.pop(null)
|
||
}
|
||
|
||
// rete di sicurezza per il restore: se il reflow non ha ancora cambiato
|
||
// contentHeight (capitoli con altezza identica), applica comunque
|
||
Timer {
|
||
id: restoreTimer
|
||
interval: 80
|
||
onTriggered: {
|
||
if (page.pendingRestore) {
|
||
page.pendingRestore = false
|
||
applyRestore()
|
||
}
|
||
}
|
||
}
|
||
|
||
// tracciamento periodico (aggiorna label e bookmark anche senza tap)
|
||
Timer {
|
||
id: trackTimer
|
||
interval: 3000
|
||
running: page.ready
|
||
repeat: true
|
||
onTriggered: readScrollPosition()
|
||
}
|
||
|
||
function overallProgress() {
|
||
if (book.chapterCount <= 0)
|
||
return 0
|
||
var total = book.chapterStartChar(book.chapterCount)
|
||
if (total <= 0)
|
||
return 0
|
||
var start = book.chapterStartChar(page.currentChapter)
|
||
return (start + page.charOffset) / total
|
||
}
|
||
|
||
// frazione di contenuto per un offset in caratteri (off >= len = fondo)
|
||
function offsetToFrac(off, chapterIndex) {
|
||
var len = book.chapterTextLength(chapterIndex)
|
||
if (len <= 0)
|
||
return 0
|
||
if (off >= len)
|
||
return 1.0
|
||
return Math.max(0, Math.min(1, off / len))
|
||
}
|
||
|
||
function applyRestore() {
|
||
var max = Math.max(0, readerFlick.contentHeight - readerFlick.height)
|
||
readerFlick.contentY = page.restoreFrac * max
|
||
readScrollPosition()
|
||
}
|
||
|
||
// il testo si è ri-impaginato (cambio capitolo, font, margini):
|
||
// applica la frazione di restore in sospeso
|
||
function onTextReflowed() {
|
||
if (page.pendingRestore) {
|
||
page.pendingRestore = false
|
||
applyRestore()
|
||
}
|
||
}
|
||
|
||
function readScrollPosition() {
|
||
if (!page.ready)
|
||
return
|
||
var vh = Math.max(1, readerFlick.height)
|
||
var max = Math.max(0, readerFlick.contentHeight - vh)
|
||
var y = Math.max(0, Math.min(max, readerFlick.contentY))
|
||
var len = book.chapterTextLength(page.currentChapter)
|
||
page.chapterFraction = max > 0 ? y / max : 0
|
||
// posizione per caratteri (stima dalla frazione: stabile col reflow)
|
||
page.charOffset = Math.round(page.chapterFraction * len)
|
||
page.pageIndex = Math.floor(y / vh) + 1
|
||
page.pageCount = Math.max(1, Math.ceil(max / vh) + 1)
|
||
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.lastSavedAbs = -1
|
||
}
|
||
page.currentChapter = index
|
||
// il binding del Text si aggiorna da solo; restore all'inizio del capitolo
|
||
page.pendingRestore = true
|
||
page.restoreFrac = page.restoreCharOffset >= 0
|
||
? offsetToFrac(page.restoreCharOffset, page.currentChapter)
|
||
: 0.0
|
||
page.restoreCharOffset = -1
|
||
restoreTimer.start()
|
||
}
|
||
|
||
function changeFontScale(delta) {
|
||
page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta))
|
||
// il binding aggiorna il font; ripristina la posizione alla frazione
|
||
// corrente (l'offset in caratteri è stabile col reflow)
|
||
page.pendingRestore = true
|
||
page.restoreFrac = page.chapterFraction
|
||
restoreTimer.start()
|
||
}
|
||
|
||
// pagina successiva: una viewport avanti (contentY nativo, affidabile);
|
||
// a fine capitolo (o capitolo non scrollabile) passa al successivo
|
||
function nextPage() {
|
||
if (!page.ready)
|
||
return
|
||
var vh = readerFlick.height
|
||
var max = Math.max(0, readerFlick.contentHeight - vh)
|
||
var y = readerFlick.contentY
|
||
if (y >= max - 2) {
|
||
// 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 {
|
||
readerFlick.contentY = Math.min(max, y + vh)
|
||
readScrollPosition()
|
||
}
|
||
}
|
||
|
||
// pagina precedente: una viewport indietro; dall'inizio del capitolo va al
|
||
// capitolo prima SULL'ULTIMA PAGINA (non salta la coda del capitolo)
|
||
function prevPage() {
|
||
if (!page.ready)
|
||
return
|
||
var y = readerFlick.contentY
|
||
if (y > 1) {
|
||
readerFlick.contentY = Math.max(0, y - readerFlick.height)
|
||
readScrollPosition()
|
||
} else if (page.currentChapter > 0) {
|
||
maybeSave(true)
|
||
page.currentChapter -= 1
|
||
page.chapterFraction = 0
|
||
page.charOffset = 0
|
||
page.lastSavedAbs = -1
|
||
// sentinella "fine capitolo": va oltre la lunghezza, così
|
||
// offsetToFrac porta all'ultima pagina
|
||
page.pendingRestore = true
|
||
page.restoreFrac = 1.0
|
||
restoreTimer.start()
|
||
}
|
||
}
|
||
|
||
function fontPx() {
|
||
return Math.round(16 * page.fontScale * 100) / 100
|
||
}
|
||
|
||
function showChapterList() {
|
||
var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"),
|
||
{ model: book.chapterList() })
|
||
dialog.accepted.connect(function() {
|
||
gotoChapter(dialog.selectedIndex)
|
||
})
|
||
}
|
||
|
||
// anteprima live dal dialogo margini: valori non ancora persistiti
|
||
function previewMargins(lr, tb) {
|
||
page.previewMarginL = lr
|
||
page.previewMarginT = tb
|
||
// il reflow è immediato (binding); mantieni la frazione di lettura
|
||
page.pendingRestore = true
|
||
page.restoreFrac = page.chapterFraction
|
||
restoreTimer.start()
|
||
}
|
||
|
||
Component.onCompleted: {
|
||
page.ready = false
|
||
if (!book.open(page.epubPath)) {
|
||
page.errorMessage = qsTr("Unable to open the EPUB file")
|
||
page.ready = true
|
||
return
|
||
}
|
||
// 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.ready = true
|
||
page.currentChapter = ch
|
||
page.pendingRestore = true
|
||
page.restoreFrac = off >= 0 ? offsetToFrac(off, ch) : 0.0
|
||
restoreTimer.start()
|
||
}
|
||
|
||
onStatusChanged: {
|
||
if (status === PageStatus.Inactive) {
|
||
maybeSave(true)
|
||
}
|
||
}
|
||
}
|