470 lines
18 KiB
QML
470 lines
18 KiB
QML
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)
|
||
}
|
||
}
|
||
}
|