Files
harbour-calibreweb/qml/pages/ReaderPage.qml
T
kaneda 6a840bc551 Lettore 0.4.2: il PushUpMenu resta aperto (snap con guardia menu.active)
Lo snap usava menu.open (vero solo a menu aperto): durante l'apertura il
pulley sposta il contenuto, lo snap scattava nel mezzo e il menu si
annullava. Ora guardia su menu.active (vero anche durante il drag) e
onClosed: snapTimer.restart() per riallineare il contenuto dopo la chiusura.
2026-08-21 11:55:20 +02:00

453 lines
17 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 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
// ogni movimento del contenuto (drag/flick) riavvia lo snap: il testo
// può essere trascinato ma torna sempre alla pagina (paginazione a tap)
onContentYChanged: snapTimer.restart()
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
PullDownMenu {
id: pullMenu
onClosed: snapTimer.restart()
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
onClosed: snapTimer.restart()
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: 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). NIENTE preventStealing:
// ruba il gesto ai pulley menu. Lo scroll libero è neutralizzato dallo
// snap alle pagine (snapTimer), non bloccando i gesti.
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()
}
}
}
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()
}
// snap alle pagine: 250 ms dopo l'ultimo movimento, riallinea contentY al
// multiplo di viewport più vicino (neutralizza lo scroll libero senza
// bloccare i gesti dei pulley menu)
Timer {
id: snapTimer
interval: 250
onTriggered: snapToPage()
}
function snapToPage() {
if (!page.ready || page.pendingRestore)
return
// menu ATTIVI (aperti O in trascinamento): il pulley sposta il contenuto
// di proposito, niente snap — altrimenti il menu si richiude da solo
if (pullMenu.active || readerMenu.active)
return
var vh = Math.max(1, readerFlick.height)
var max = Math.max(0, readerFlick.contentHeight - vh)
var y = readerFlick.contentY
var target
// ultima pagina "piena" allineata alla viewport
var last = Math.floor(max / vh) * vh
if (y > last + 2) {
// zona finale parziale: snap al fondo (evita il loop round->indietro)
target = max
} else {
// soglia al 70% della pagina: trascinamenti piccoli tornano alla
// pagina corrente, oltre metà vanno alla pagina dopo
target = Math.round(y / vh - 0.2) * vh
}
target = Math.max(0, Math.min(max, target))
if (Math.abs(target - y) > 2)
readerFlick.contentY = target
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 (y a fondo, con lo snap allineato) 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 (o capitolo non scrollabile): segna la coda e passa
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)
}
}
}