0.5.0: nuovo motore di lettura PagedTextItem (QQuickPaintedItem + QTextDocument, pattern harbour-books): pagine misurate dal layout, margini e notch su ogni pagina, posizione per carattere stabile a reflow/rotazione
This commit is contained in:
+101
-172
@@ -2,14 +2,14 @@ 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).
|
||||
// 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
|
||||
|
||||
@@ -18,11 +18,11 @@ Page {
|
||||
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)
|
||||
// posizione di lettura nel capitolo corrente in caratteri di testo
|
||||
// (metrica del capitolo, coerente coi segnalibri salvati)
|
||||
property int charOffset: 0
|
||||
// offset da ripristinare al cambio capitolo (-1 = nessuno)
|
||||
property int restoreCharOffset: -1
|
||||
// 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)
|
||||
@@ -30,11 +30,8 @@ Page {
|
||||
property int pageCount: 1
|
||||
property bool ready: false
|
||||
property string errorMessage: ""
|
||||
// reflow: scala il font (il testo si ri-avvolge), persistita in appSettings
|
||||
// reflow: scala il font (il testo si ri-impagina), 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
|
||||
@@ -45,8 +42,14 @@ Page {
|
||||
// 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).
|
||||
property int notchInset: page.isPortrait ? Theme.statusBarHeight : 0
|
||||
// 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
|
||||
@@ -149,62 +152,33 @@ Page {
|
||||
}
|
||||
}
|
||||
|
||||
// contenuto: Flickable NON interattivo (lo scroll è solo programmatico).
|
||||
// La finestra di lettura VIVE DENTRO i margini verticali (top/bottom
|
||||
// margin): il testo non puo' fisicamente uscire dai bordi, quindi i
|
||||
// margini alto/basso sono identici su OGNI pagina (non solo a inizio
|
||||
// capitolo) e si aggiornano live con l'anteprima del dialog.
|
||||
Flickable {
|
||||
id: innerFlick
|
||||
// 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
|
||||
contentHeight: readerContent.height
|
||||
interactive: false
|
||||
clip: true
|
||||
|
||||
Item {
|
||||
id: readerContent
|
||||
width: parent.width
|
||||
// Altezza = testo + "deficit" dell'ultima pagina: max-scroll
|
||||
// diventa k*step esatto e l'ultima pagina e' allineata (niente
|
||||
// testo ripetuto tra penultima e ultima). Calcolo INLINE nel
|
||||
// binding: si rivaluta quando cambia la finestra (rotazione,
|
||||
// anteprima margini) o l'altezza del testo (reflow).
|
||||
height: {
|
||||
var step = Math.max(1, parent.height)
|
||||
var t = textItem.height
|
||||
var pad = 0
|
||||
if (t > 0) {
|
||||
var r = t % step
|
||||
pad = r === 0 ? 0 : step - r
|
||||
}
|
||||
return Math.max(parent.height, t + pad)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: textItem
|
||||
x: page.previewMarginL >= 0 ? page.previewMarginL : appSettings.readerMargin
|
||||
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()
|
||||
}
|
||||
}
|
||||
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). NIENTE preventStealing:
|
||||
// il drag non ha effetti perche' il contenuto non e' scrollabile.
|
||||
// menu (PullDown in alto, PushUp in basso).
|
||||
MouseArea {
|
||||
id: tapZone
|
||||
anchors.fill: parent
|
||||
@@ -233,28 +207,28 @@ Page {
|
||||
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()
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// tracciamento periodico (aggiorna label e bookmark anche senza tap)
|
||||
Timer {
|
||||
id: trackTimer
|
||||
interval: 3000
|
||||
running: page.ready
|
||||
repeat: true
|
||||
onTriggered: readScrollPosition()
|
||||
// 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() {
|
||||
@@ -267,44 +241,13 @@ Page {
|
||||
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() {
|
||||
// aggiorna label, cover e segnalibro dopo un cambio pagina o un reflow
|
||||
function syncState() {
|
||||
if (!page.ready)
|
||||
return
|
||||
// passo di pagina = altezza della finestra di lettura (i margini
|
||||
// alto/basso sono FUORI dal Flickable: nessun testo li attraversa)
|
||||
var step = Math.max(1, innerFlick.height)
|
||||
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)
|
||||
page.pageIndex = reader.page + 1
|
||||
page.pageCount = reader.pageCount
|
||||
page.charOffset = offsetForPos()
|
||||
updateCoverState()
|
||||
maybeSave(false)
|
||||
}
|
||||
@@ -315,7 +258,7 @@ Page {
|
||||
// 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)
|
||||
// di almeno 20 caratteri (evita scritture continue)
|
||||
if (force || Math.abs(abs - page.lastSavedAbs) > 20) {
|
||||
page.lastSavedAbs = abs
|
||||
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
|
||||
@@ -328,71 +271,52 @@ Page {
|
||||
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()
|
||||
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
|
||||
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()
|
||||
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: una pagina di testo = altezza della finestra di
|
||||
// lettura; a fine capitolo passa al successivo
|
||||
// pagina successiva: a fine capitolo passa al successivo
|
||||
function nextPage() {
|
||||
if (!page.ready)
|
||||
return
|
||||
var step = Math.max(1, innerFlick.height)
|
||||
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
|
||||
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 {
|
||||
innerFlick.contentY = Math.min(max, y + step)
|
||||
readScrollPosition()
|
||||
reader.nextPage()
|
||||
}
|
||||
}
|
||||
|
||||
// pagina precedente: una pagina indietro; dall'inizio del capitolo va al
|
||||
// capitolo prima SULL'ULTIMA PAGINA (non salta la coda del capitolo)
|
||||
// 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
|
||||
var step = Math.max(1, innerFlick.height)
|
||||
var y = innerFlick.contentY
|
||||
if (y > 1) {
|
||||
innerFlick.contentY = Math.max(0, y - step)
|
||||
readScrollPosition()
|
||||
if (reader.page > 0) {
|
||||
reader.previousPage()
|
||||
} 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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,14 +343,23 @@ Page {
|
||||
})
|
||||
}
|
||||
|
||||
// anteprima live dal dialogo margini: valori non ancora persistiti
|
||||
// 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
|
||||
// il reflow è immediato (binding); mantieni la posizione per caratteri
|
||||
page.restoreFrac = offsetToFrac(page.charOffset, page.currentChapter)
|
||||
page.pendingRestore = true
|
||||
restoreTimer.start()
|
||||
}
|
||||
|
||||
// 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: {
|
||||
@@ -449,11 +382,7 @@ Page {
|
||||
}
|
||||
}
|
||||
page.ready = true
|
||||
page.currentChapter = ch
|
||||
page.pendingRestore = true
|
||||
page.restoreFrac = off >= 0 ? offsetToFrac(off, ch) : 0.0
|
||||
updateCoverState()
|
||||
restoreTimer.start()
|
||||
loadChapter(ch, off)
|
||||
}
|
||||
|
||||
Component.onDestruction: readerState.clearReading()
|
||||
|
||||
Reference in New Issue
Block a user