399 lines
15 KiB
QML
399 lines
15 KiB
QML
import QtQuick 2.0
|
||
import Sailfish.Silica 1.0
|
||
import harbour.calibreweb 1.0
|
||
|
||
// Lettore a pagine REALI: il capitolo (rich text Qt) viene impaginato da
|
||
// PagedTextItem (QTextDocument + QPainter, pattern QQuickPaintedItem come
|
||
// harbour-books) in pagine misurate dal layout: niente scroll, niente stime,
|
||
// niente testo che sborda e l'ultima pagina e' allineata da sola.
|
||
// La finestra di lettura e' la GEOMETRIA dell'item: i margini (laterali,
|
||
// alto/basso) e l'inset del notch valgono su OGNI pagina.
|
||
// La posizione di lettura e' un indice di carattere: sopravvive al reflow del
|
||
// font e alla rotazione senza timer ne' stime.
|
||
Page {
|
||
id: page
|
||
|
||
property string epubPath: ""
|
||
property int bookId: 0
|
||
property string bookFormat: "epub"
|
||
|
||
property int currentChapter: 0
|
||
// posizione di lettura nel capitolo corrente in caratteri di testo
|
||
// (metrica del capitolo, coerente coi segnalibri salvati)
|
||
property int charOffset: 0
|
||
// caratteri da ripristinare nel capitolo appena aperto (-1 = nessuno)
|
||
property int pendingChars: -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-impagina), persistita in appSettings
|
||
property double fontScale: appSettings.readerFontScale
|
||
// 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). Theme.statusBarHeight esiste nelle Silica
|
||
// recenti: se manca si usa un valore prudente.
|
||
function statusBarInset() {
|
||
var h = Theme.statusBarHeight
|
||
return (typeof h === "number" && h > 0) ? h : 48
|
||
}
|
||
|
||
property int notchInset: page.isPortrait ? page.statusBarInset() : 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
|
||
}
|
||
}
|
||
|
||
// Motore di paginazione: l'item impagina il rich text del capitolo e
|
||
// disegna la pagina corrente. I margini sono anchors: la finestra di
|
||
// lettura vive DENTRO i margini, quindi il testo non puo' uscirne.
|
||
PagedTextItem {
|
||
id: reader
|
||
anchors.fill: parent
|
||
anchors.leftMargin: page.previewMarginL >= 0
|
||
? page.previewMarginL : appSettings.readerMargin
|
||
anchors.rightMargin: page.previewMarginL >= 0
|
||
? page.previewMarginL : appSettings.readerMargin
|
||
anchors.topMargin: page.notchInset + (page.previewMarginT >= 0
|
||
? page.previewMarginT
|
||
: appSettings.readerMarginV)
|
||
anchors.bottomMargin: page.previewMarginT >= 0
|
||
? page.previewMarginT : appSettings.readerMarginV
|
||
fontFamily: Theme.fontFamily
|
||
fontPixelSize: page.fontPx()
|
||
textColor: Theme.primaryColor
|
||
// il reflow (font, geometria) mantiene la posizione internamente:
|
||
// qui si aggiornano solo label, cover e segnalibro
|
||
onPageChanged: page.syncState()
|
||
onLayoutChanged: page.syncState()
|
||
}
|
||
|
||
// 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).
|
||
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)
|
||
}
|
||
|
||
// posizione di lettura nella metrica del capitolo (0..1), dal motore
|
||
function readerFrac() {
|
||
var n = reader.characterCount
|
||
return n > 0 ? reader.position / n : 0.0
|
||
}
|
||
|
||
// converte un offset di caratteri (metrica capitolo) nella posizione del
|
||
// motore: le due metriche differiscono di poco, la frazione le allinea
|
||
function posForOffset(off) {
|
||
var len = book.chapterTextLength(page.currentChapter)
|
||
var n = reader.characterCount
|
||
if (len <= 0 || n <= 0)
|
||
return 0
|
||
if (off >= len)
|
||
return Math.max(0, n - 1)
|
||
return Math.round((off / len) * n)
|
||
}
|
||
|
||
// offset di caratteri (metrica capitolo) della pagina corrente: e' quello
|
||
// che finisce nel segnalibro, coerente con le chiavi salvate (v2)
|
||
function offsetForPos() {
|
||
return Math.round(readerFrac() * book.chapterTextLength(page.currentChapter))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// aggiorna label, cover e segnalibro dopo un cambio pagina o un reflow
|
||
function syncState() {
|
||
if (!page.ready)
|
||
return
|
||
page.pageIndex = reader.page + 1
|
||
page.pageCount = reader.pageCount
|
||
page.charOffset = offsetForPos()
|
||
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)
|
||
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.lastSavedAbs = -1
|
||
}
|
||
page.currentChapter = index
|
||
page.pendingChars = -1
|
||
// carica il capitolo e vai all'inizio (posizione esplicita)
|
||
reader.html = book.chapterRichText(index)
|
||
reader.goToPosition(0)
|
||
syncState()
|
||
}
|
||
|
||
function changeFontScale(delta) {
|
||
// limite ampio e passo 0.1: tanti ingrandimenti prima del limite
|
||
appSettings.readerFontScale = Math.max(0.7, Math.min(3.5, page.fontScale + delta))
|
||
// il binding aggiorna fontPixelSize: il motore si ri-impagina e
|
||
// mantiene da solo la posizione per carattere
|
||
}
|
||
|
||
// pagina successiva: a fine capitolo passa al successivo
|
||
function nextPage() {
|
||
if (!page.ready)
|
||
return
|
||
if (reader.page >= reader.pageCount - 1) {
|
||
// fine capitolo: segna la coda e passa al capitolo dopo
|
||
page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1)
|
||
maybeSave(true)
|
||
gotoChapter(page.currentChapter + 1)
|
||
} else {
|
||
reader.nextPage()
|
||
}
|
||
}
|
||
|
||
// pagina precedente: dall'inizio del capitolo va al capitolo prima
|
||
// SULL'ULTIMA PAGINA (non salta la coda del capitolo)
|
||
function prevPage() {
|
||
if (!page.ready)
|
||
return
|
||
if (reader.page > 0) {
|
||
reader.previousPage()
|
||
} else if (page.currentChapter > 0) {
|
||
maybeSave(true)
|
||
var prev = page.currentChapter - 1
|
||
page.currentChapter = prev
|
||
reader.html = book.chapterRichText(prev)
|
||
// posizione esplicita: fondo del capitolo precedente
|
||
reader.goToPosition(Math.max(0, reader.characterCount - 1))
|
||
syncState()
|
||
}
|
||
}
|
||
|
||
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: la geometria dell'item cambia e il
|
||
// motore si ri-impagina mantenendo la posizione di lettura
|
||
function previewMargins(lr, tb) {
|
||
page.previewMarginL = lr
|
||
page.previewMarginT = tb
|
||
}
|
||
|
||
// apre un capitolo e va alla posizione indicata (metrica capitolo)
|
||
function loadChapter(index, offset) {
|
||
page.currentChapter = index
|
||
reader.html = book.chapterRichText(index)
|
||
var target = offset >= 0
|
||
? Math.round((offset / Math.max(1, book.chapterTextLength(index)))
|
||
* reader.characterCount)
|
||
: 0
|
||
reader.goToPosition(target)
|
||
syncState()
|
||
}
|
||
|
||
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
|
||
loadChapter(ch, off)
|
||
}
|
||
|
||
Component.onDestruction: readerState.clearReading()
|
||
|
||
onStatusChanged: {
|
||
if (status === PageStatus.Active) {
|
||
if (page.ready && book.chapterCount > 0)
|
||
updateCoverState()
|
||
} else if (status === PageStatus.Inactive) {
|
||
maybeSave(true)
|
||
}
|
||
}
|
||
}
|