import QtQuick 2.0 import Sailfish.Silica 1.0 import harbour.calibreweb 1.0 // Lettore senza WebView: il capitolo è renderizzato come rich text Qt in un // Text QML dentro un SilicaFlickable. Lo scroll (contentY/contentHeight) è // nativo QML e sempre affidabile — sul WebView di Sailfish lo scroll // programmatico e la lettura della posizione non funzionano (window.scrollY // stale, contentHeight/scrollPosition non scrivibili): con il WebView il // cambio capitolo non scattava mai. 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: readerFlick anchors.fill: parent contentHeight: readerContent.height // ogni movimento del contenuto (drag/flick) riavvia lo snap: il testo // può essere trascinato ma torna sempre alla pagina (paginazione a tap) onContentYChanged: snapTimer.restart() // 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 del capitolo: rich text Qt in un Text QML. Il cambio di // currentChapter aggiorna il binding; il reflow (font/margini) è nativo. 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: // ruba il gesto ai pulley menu. Lo scroll libero è neutralizzato dallo // snap alle pagine (snapTimer), non bloccando i gesti. 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() } // snap alle pagine: 250 ms dopo l'ultimo movimento, riallinea contentY al // multiplo di viewport più vicino (neutralizza lo scroll libero senza // bloccare i gesti dei pulley menu) Timer { id: snapTimer interval: 250 onTriggered: snapToPage() } function snapToPage() { if (!page.ready || page.pendingRestore) return // menu aperti: il contenuto è spostato apposta, niente snap if (pullMenu.open || readerMenu.open) return var vh = Math.max(1, readerFlick.height) var max = Math.max(0, readerFlick.contentHeight - vh) var y = readerFlick.contentY var target // ultima pagina "piena" allineata alla viewport var last = Math.floor(max / vh) * vh if (y > last + 2) { // zona finale parziale: snap al fondo (evita il loop round->indietro) target = max } else { // soglia al 70% della pagina: trascinamenti piccoli tornano alla // pagina corrente, oltre metà vanno alla pagina dopo target = Math.round(y / vh - 0.2) * vh } target = Math.max(0, Math.min(max, target)) if (Math.abs(target - y) > 2) readerFlick.contentY = target 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, readerFlick.contentHeight - readerFlick.height) readerFlick.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, readerFlick.height) var max = Math.max(0, readerFlick.contentHeight - vh) var y = Math.max(0, Math.min(max, readerFlick.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 nativo, affidabile); // a fine capitolo (y a fondo, con lo snap allineato) passa al successivo function nextPage() { if (!page.ready) return var vh = readerFlick.height var max = Math.max(0, readerFlick.contentHeight - vh) var y = readerFlick.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 { readerFlick.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 = readerFlick.contentY if (y > 1) { readerFlick.contentY = Math.max(0, y - readerFlick.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) } } }