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 // Sfondo di lettura UNIFORME (invece del gradiente di sistema): le fasce // dei margini usano questo stesso colore, quindi margine e sfondo sono // indistinguibili su ogni pagina (niente bande visibili, niente testo // "coperto" percepibile: le righe spariscono nel margine). property color readerBgColor: Theme.colorScheme === Theme.LightOnDark ? "#FF0E0E0E" : "#FFFFFFFF" EpubBook { id: book } // sfondo pieno del lettore, dietro tutto il resto Rectangle { anchors.fill: parent z: -1 color: page.readerBgColor } 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) Flickable { id: innerFlick anchors.fill: parent contentHeight: readerContent.height interactive: false clip: true Item { id: readerContent width: parent.width // Il contenitore ha il padding verticale (margine) + il "deficit" // dell'ultima pagina: cosi' max-scroll = k*step e l'ultima pagina // e' allineata (niente testo ripetuto tra penultima e ultima). height: Math.max(parent.height, textItem.height + 2 * page.marginV() + page.lastPagePad()) 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() } } } // Margini verticali VISTI su ogni schermata: due fasce opache (colore di // sfondo) coprono il testo a cavallo dei bordi della viewport. Il testo // scorre tra le fasce con passo = viewport - 2*margine: ogni pagina // mostra esattamente un blocco di testo con margine sopra e sotto. Rectangle { id: topBand anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top height: page.marginV() visible: height > 0 z: 5 color: page.readerBgColor } Rectangle { id: bottomBand anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom height: page.marginV() visible: height > 0 z: 5 color: page.readerBgColor } // 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 = viewport - 2*margine verticale: ogni pagina mostra // il testo DENTRO i margini (niente righe a filo del bordo) var step = Math.max(1, innerFlick.height - 2 * page.marginV()) 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) } // margine verticale effettivo (anteprima dialog oppure impostazioni) function marginV() { return page.previewMarginT >= 0 ? page.previewMarginT : appSettings.readerMarginV } // deficit dell'ultima pagina: porta max-scroll a un multiplo esatto del // passo, cosi' l'ultima pagina mostra il testo residuo SENZA sovrapporsi // alla penultima (niente righe ripetute) e con il margine sotto function lastPagePad() { var step = Math.max(1, innerFlick.height - 2 * page.marginV()) var t = textItem.height if (t <= 0) return 0 var r = t % step return r === 0 ? 0 : step - r } 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 = viewport meno i margini verticali // (così ogni pagina ha il testo dentro i margini, non a filo del bordo); // a fine capitolo passa al successivo function nextPage() { if (!page.ready) return var step = Math.max(1, innerFlick.height - 2 * page.marginV()) 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 - 2 * page.marginV()) 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) } } }