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

399 lines
15 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 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)
}
}
}