import QtQuick 2.0 import Sailfish.Silica 1.0 import Sailfish.WebView 1.0 import harbour.calibreweb 1.0 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, in caratteri di testo // (conteggio JS sui text node: stabile al variare di font/margini) property int charOffset: 0 // offset da ripristinare al load del 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 bool restoring: false property string errorMessage: "" // reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico property double fontScale: 1.0 allowedOrientations: Orientation.All EpubBook { id: book } SilicaFlickable { anchors.fill: parent contentHeight: parent.height // il PullDownMenu va DENTRO una vista Silica, non a livello di Page PullDownMenu { 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; rinfresca subito applyMarginsJS() }) } } 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 } } WebView { id: web anchors.fill: parent active: true onLoadedChanged: { if (!loaded) return loadWatchdog.stop() page.ready = true // sblocca lo scroll se il CSS del libro lo blocca, poi ripristina // la posizione per OFFSET DI CARATTERE (stabile con font/margini) web.runJavaScript(page.unlockScrollJs(), function() { if (page.restoreCharOffset >= 0) { var off = page.restoreCharOffset page.restoreCharOffset = -1 if (off >= book.chapterTextLength(page.currentChapter)) { // oltre la fine = vai all'ultima pagina (usato da // prevPage per non saltare la coda del capitolo) web.runJavaScript( "window.scrollTo(0,Math.max(1," + "document.documentElement.scrollHeight-window.innerHeight));true", function() { readScrollPosition() }) } else { web.runJavaScript(page.scrollToCharJs(off), function() { readScrollPosition() }) } } else { readScrollPosition() } }) page.restoring = false } } // Paginazione a tap: niente scroll libero. 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() } } } BusyIndicator { anchors.centerIn: parent running: !page.ready size: BusyIndicatorSize.Large } // watchdog: se il capitolo non si carica, niente spinner infinito Timer { id: loadWatchdog interval: 15000 onTriggered: { if (!web.loaded && !page.ready) { page.errorMessage = qsTr("The reader is not responding (WebView). The EPUB file may be damaged.") page.ready = true } } } // ritardo di apertura: evita la race tra onCompleted e l'init del WebView Timer { id: openTimer interval: 100 onTriggered: openChapter() } // chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage) Timer { id: closeBookTimer interval: 50 onTriggered: pageStack.pop(null) } Label { anchors.centerIn: parent text: page.errorMessage color: Theme.errorColor visible: page.errorMessage.length > 0 } // ---- controlli spostati nel PushUpMenu (trascina in alto) ---- // ---- timer di tracciamento ---- Timer { id: trackTimer interval: 2000 running: page.ready repeat: true onTriggered: readScrollPosition() } function overallProgress() { if (book.chapterCount <= 0) return 0 return (page.currentChapter + page.chapterFraction) / book.chapterCount } // JS che sblocca lo scroll del documento: rimuove overflow hidden / altezze // fisse che i CSS di alcuni EPUB impongono (sintomo: max==0, ogni tap salta // al capitolo successivo saltando tutto il testo) function unlockScrollJs() { return "(function(){" + "var de=document.documentElement,b=document.body;" + "if(!de||!b)return false;" + "de.style.setProperty('overflow','visible','important');" + "b.style.setProperty('overflow','visible','important');" + "de.style.setProperty('height','auto','important');" + "b.style.setProperty('height','auto','important');" + "var after=Math.max(de.scrollHeight,b.scrollHeight);" + "if(after<=window.innerHeight+1&&b.textContent.length>0){" + "var all=b.querySelectorAll('div,section,article,main,td,li');" + "for(var i=0;i=0)break;" + "var h=rect.bottom-rect.top;" + "if(h>0)o+=Math.round(n.data.length*(0-rect.top)/h);" + "break;}" + "return JSON.stringify({o:o,f:y/max,p:Math.floor(y/vh)+1," + "pc:Math.max(1,Math.floor(max/vh)+1)});})()" } // JS che porta in cima alla viewport il carattere 'off' del capitolo function scrollToCharJs(off) { return "(function(){" + "var b=document.body,o=0;" + "var it=document.createTreeWalker(b,NodeFilter.SHOW_TEXT);" + "var n;" + "while((n=it.nextNode())){" + "var p=n.parentNode;" + "if(!p||p.tagName==='SCRIPT'||p.tagName==='STYLE'||p.tagName==='NOSCRIPT')continue;" + "if(o+n.data.length>=" + off + "){" + "var r=document.createRange();r.setStart(n," + off + "-o);r.collapse(true);" + "var rect=r.getBoundingClientRect();" + "if(rect.top>0)window.scrollTo(0,rect.top+window.scrollY-8);" + "return true;}" + "o+=n.data.length;}" + "return false;})()" } function readScrollPosition() { if (!web.loaded) return web.runJavaScript(page.positionJs(), function(res) { var r = JSON.parse(res) page.charOffset = r.o page.chapterFraction = r.f page.pageIndex = r.p page.pageCount = r.pc 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.restoreCharOffset = -1 page.lastSavedAbs = -1 } page.currentChapter = index loadChapter() } function changeFontScale(delta) { page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta)) // ricarica con la nuova dimensione, mantenendo la posizione di lettura: // l'offset in CARATTERI è stabile col reflow (a differenza dei px scrollati) page.restoreCharOffset = page.charOffset loadChapter() } // pagina successiva: una viewport avanti. Lo scroll avviene con scrollBy e // la verifica è "lo scroll si è MOSSO?" — non un confronto con max calcolato // (innerHeight/scrollHeight del WebView non sempre corrispondono allo scroll // reale: con max sovrastimato scrollY non lo raggiungeva mai e il passaggio // al capitolo successivo non scattava). Se non si muove: unlock CSS e riprova; // se ancora fermo, siamo a fine capitolo (o capitolo non scrollabile) -> avanti. function nextPage() { if (!web.loaded || !page.ready) return web.runJavaScript( "(function(){" + "var de=document.documentElement,b=document.body;" + "var vh=window.innerHeight;" + "var before=window.scrollY;" + "window.scrollBy(0,vh);" + "if(window.scrollY>before+1)" + "return JSON.stringify({ok:true,atEnd:false});" + "de.style.setProperty('overflow','visible','important');" + "b.style.setProperty('overflow','visible','important');" + "de.style.setProperty('height','auto','important');" + "b.style.setProperty('height','auto','important');" + "window.scrollBy(0,vh);" + "if(window.scrollY>before+1)" + "return JSON.stringify({ok:true,atEnd:false});" + "return JSON.stringify({ok:false,atEnd:true});})()", function(res) { var r = JSON.parse(res) if (r.ok) { readScrollPosition() } else if (r.atEnd) { gotoChapter(page.currentChapter + 1) } }) } // pagina precedente: una viewport indietro; se lo scroll non si muove siamo // all'inizio -> capitolo prima SULL'ULTIMA PAGINA (non salta la coda) function prevPage() { if (!web.loaded || !page.ready) return web.runJavaScript( "(function(){" + "var vh=window.innerHeight;" + "var before=window.scrollY;" + "window.scrollBy(0,-vh);" + "if(window.scrollY 0) { maybeSave(true) page.currentChapter -= 1 page.chapterFraction = 0 page.charOffset = 0 // sentinella "fine capitolo": va oltre la lunghezza, così // onLoadedChanged porta all'ultima pagina page.restoreCharOffset = book.chapterTextLength(page.currentChapter) + 1 page.lastSavedAbs = -1 loadChapter() } }) } function fontPx() { return Math.round(16 * page.fontScale * 100) / 100 } // carica il capitolo corrente via loadHtml: lo stile di reflow (font-size // e margini) è iniettato nell'HTML dal parser, quindi niente JS post-load function loadChapter() { var html = book.chapterHtml(page.currentChapter, page.fontPx(), appSettings.readerMargin, appSettings.readerMarginV) if (html.length === 0) { page.errorMessage = qsTr("Chapter unreadable") page.ready = true return } page.ready = false page.restoring = true loadWatchdog.restart() web.loadHtml(html, book.chapterBaseUrl(page.currentChapter)) } function showChapterList() { var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"), { model: book.chapterList() }) dialog.accepted.connect(function() { gotoChapter(dialog.selectedIndex) }) } // JS che sovrascrive i margini sul documento aperto: stile inline con // 'important' batte lo stylesheet iniettato. Effetto immediato, niente reload. function marginJs(lr, tb) { return "var h=document.documentElement,b=document.body;" + "h.style.setProperty('margin-left','" + lr + "px','important');" + "h.style.setProperty('margin-right','" + lr + "px','important');" + "b.style.setProperty('margin-left','" + lr + "px','important');" + "b.style.setProperty('margin-right','" + lr + "px','important');" + "b.style.setProperty('margin-top','" + tb + "px','important');" + "b.style.setProperty('margin-bottom','" + tb + "px','important');" + "true" } // riapplica i margini salvati nelle impostazioni (senza ricaricare il libro) function applyMarginsJS() { if (!web.loaded || !page.ready) return web.runJavaScript(marginJs(appSettings.readerMargin, appSettings.readerMarginV), function() { }) } // anteprima live dal dialogo margini: valori non ancora persistiti function previewMargins(lr, tb) { if (!web.loaded || !page.ready) return web.runJavaScript(marginJs(lr, tb), function() { }) } Component.onCompleted: { page.ready = false if (!book.open(page.epubPath)) { page.errorMessage = qsTr("Unable to open the EPUB file") page.ready = true return } openTimer.start() } function openChapter() { // 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.currentChapter = ch page.chapterFraction = 0 page.charOffset = 0 page.restoreCharOffset = off page.lastSavedAbs = -1 loadChapter() } onStatusChanged: { if (status === PageStatus.Inactive) { maybeSave(true) } else if (status === PageStatus.Active) { // riapplica i margini se cambiati nelle impostazioni: // via JS sul documento aperto, senza ricaricare il capitolo if (page.ready && book.chapterCount() > 0) applyMarginsJS() } } }