Files
harbour-calibreweb/qml/pages/ReaderPage.qml
T

470 lines
18 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 è rich text Qt in un Text QML.
// STRUTTURA A PAGINE RIGIDE:
// - SilicaFlickable ESTERNO con contentHeight = height: NON scrollabile,
// serve solo per i pulley menu (PullDown/PushUp) che funzionano dal bordo
// - Flickable INTERNO con interactive:false: il dito non lo scrolla MAI,
// contentY cambia solo col tap (paginazione programmatica)
// Risultato: niente scroll libero, niente snap, menu sempre funzionanti,
// paginazione a tap affidabile al 100% (proprietà QML native).
Page {
id: page
property string epubPath: ""
property int bookId: 0
property string bookFormat: "epub"
property int currentChapter: 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), persistita in appSettings
property double fontScale: appSettings.readerFontScale
// 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
// Inset superiore per display con notch (Jolla Phone 2026): la finestra
// dell'app inizia sopra la fine del ritaglio, quindi in portrait la zona di
// lettura parte piu' in basso di un'altezza barra di stato, e il testo non
// finisce mai sotto il notch (in landscape il notch e' laterale: gestito
// dai margini orizzontali).
property int notchInset: page.isPortrait ? Theme.statusBarHeight : 0
EpubBook {
id: book
}
SilicaFlickable {
id: outerFlick
anchors.fill: parent
// NON scrollabile: il contenuto non supera mai la viewport
contentHeight: outerFlick.height
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
PullDownMenu {
id: pullMenu
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.1)
}
Button {
text: "A+"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(0.1)
}
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: Flickable NON interattivo (lo scroll è solo programmatico).
// La finestra di lettura VIVE DENTRO i margini verticali (top/bottom
// margin): il testo non puo' fisicamente uscire dai bordi, quindi i
// margini alto/basso sono identici su OGNI pagina (non solo a inizio
// capitolo) e si aggiornano live con l'anteprima del dialog.
Flickable {
id: innerFlick
anchors.fill: parent
anchors.topMargin: page.notchInset + (page.previewMarginT >= 0
? page.previewMarginT
: appSettings.readerMarginV)
anchors.bottomMargin: page.previewMarginT >= 0
? page.previewMarginT : appSettings.readerMarginV
contentHeight: readerContent.height
interactive: false
clip: true
Item {
id: readerContent
width: parent.width
// Altezza = testo + "deficit" dell'ultima pagina: max-scroll
// diventa k*step esatto e l'ultima pagina e' allineata (niente
// testo ripetuto tra penultima e ultima). Calcolo INLINE nel
// binding: si rivaluta quando cambia la finestra (rotazione,
// anteprima margini) o l'altezza del testo (reflow).
height: {
var step = Math.max(1, parent.height)
var t = textItem.height
var pad = 0
if (t > 0) {
var r = t % step
pad = r === 0 ? 0 : step - r
}
return Math.max(parent.height, t + pad)
}
Text {
id: textItem
x: page.previewMarginL >= 0 ? page.previewMarginL : appSettings.readerMargin
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:
// il drag non ha effetti perche' il contenuto non e' scrollabile.
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: il caso normale è applicato da
// onTextReflowed appena il reflow cambia contentHeight; questo timer
// copre i rari casi in cui l'altezza non cambia (capitoli identici).
// Ritardo lungo: il reflow rich text può richiedere qualche centinaio di ms.
Timer {
id: restoreTimer
interval: 500
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, innerFlick.contentHeight - innerFlick.height)
innerFlick.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
// passo di pagina = altezza della finestra di lettura (i margini
// alto/basso sono FUORI dal Flickable: nessun testo li attraversa)
var step = Math.max(1, innerFlick.height)
var max = Math.max(0, innerFlick.contentHeight - innerFlick.height)
var y = Math.max(0, Math.min(max, innerFlick.contentY))
var len = book.chapterTextLength(page.currentChapter)
// posizione per caratteri (stima dalla frazione: stabile col reflow)
page.charOffset = Math.round((max > 0 ? y / max : 0) * len)
page.pageIndex = Math.floor(y / step) + 1
page.pageCount = Math.max(1, Math.ceil(max / step) + 1)
updateCoverState()
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.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) {
// limite ampio e passo 0.1: tanti ingrandimenti prima del limite
page.fontScale = Math.max(0.7, Math.min(3.5, page.fontScale + delta))
// persisti la scala: riparte da qui alla prossima apertura
appSettings.readerFontScale = page.fontScale
// il binding aggiorna il font; ripristina la posizione per CARATTERI
// (stabile col reflow, a differenza della frazione di scroll: con il
// testo più alto la stessa frazione mostrerebbe un punto diverso)
page.restoreFrac = offsetToFrac(page.charOffset, page.currentChapter)
page.pendingRestore = true
restoreTimer.start()
}
// pagina successiva: una pagina di testo = altezza della finestra di
// lettura; a fine capitolo passa al successivo
function nextPage() {
if (!page.ready)
return
var step = Math.max(1, innerFlick.height)
var max = Math.max(0, innerFlick.contentHeight - innerFlick.height)
var y = innerFlick.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 {
innerFlick.contentY = Math.min(max, y + step)
readScrollPosition()
}
}
// pagina precedente: una pagina 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 step = Math.max(1, innerFlick.height)
var y = innerFlick.contentY
if (y > 1) {
innerFlick.contentY = Math.max(0, y - step)
readScrollPosition()
} else if (page.currentChapter > 0) {
maybeSave(true)
page.currentChapter -= 1
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
}
// aggiorna lo stato per la cover (titolo + capitolo + percentuale)
function updateCoverState() {
if (book.chapterCount <= 0)
return
var pct = Math.round(page.overallProgress() * 100)
readerState.setReading(book.title(),
(page.currentChapter + 1) + "/" + book.chapterCount
+ " · " + book.chapterTitle(page.currentChapter)
+ " · " + pct + "%")
}
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 posizione per caratteri
page.restoreFrac = offsetToFrac(page.charOffset, page.currentChapter)
page.pendingRestore = true
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
updateCoverState()
restoreTimer.start()
}
Component.onDestruction: readerState.clearReading()
onStatusChanged: {
if (status === PageStatus.Active) {
if (page.ready && book.chapterCount > 0)
updateCoverState()
} else if (status === PageStatus.Inactive) {
maybeSave(true)
}
}
}