SilicaFlickable esterno non scrollabile (contentHeight=height) solo per i pulley menu + Flickable interno interactive:false: il dito non muove mai il testo, contentY cambia solo col tap. Rimossi snap/preventStealing/onClosed/ active: niente piu' compromessi scroll<->menu, i pulley funzionano sempre.
421 lines
15 KiB
QML
421 lines
15 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
|
||
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: 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.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: Flickable NON interattivo (lo scroll è solo programmatico)
|
||
Flickable {
|
||
id: innerFlick
|
||
anchors.fill: parent
|
||
contentHeight: readerContent.height
|
||
interactive: false
|
||
clip: true
|
||
|
||
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:
|
||
// 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: 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, 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
|
||
var vh = Math.max(1, innerFlick.height)
|
||
var max = Math.max(0, innerFlick.contentHeight - vh)
|
||
var y = Math.max(0, Math.min(max, innerFlick.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 programmatico, il dito
|
||
// non può scrollare); a fine capitolo passa al successivo
|
||
function nextPage() {
|
||
if (!page.ready)
|
||
return
|
||
var vh = innerFlick.height
|
||
var max = Math.max(0, innerFlick.contentHeight - vh)
|
||
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 + 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 = innerFlick.contentY
|
||
if (y > 1) {
|
||
innerFlick.contentY = Math.max(0, y - innerFlick.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)
|
||
}
|
||
}
|
||
}
|