Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
This commit is contained in:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.toArgb
|
|||
import androidx.compose.ui.platform.SoftwareKeyboardController
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
|
@ -56,12 +57,16 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import com.aryan.reader.shared.pdf.SHARED_PDF_RICH_TEXT_LOG_TAG
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
const val PAGE_BREAK_CHAR = '\u000C'
|
||||
private const val ZWSP = "\u200B"
|
||||
|
||||
internal fun String.hasRenderableRichText(): Boolean =
|
||||
any { it != PAGE_BREAK_CHAR && !it.isWhitespace() }
|
||||
|
||||
object PdfFontCache {
|
||||
private val cache = ConcurrentHashMap<String, FontFamily>()
|
||||
private var assetManager: android.content.res.AssetManager? = null
|
||||
|
|
@ -262,126 +267,200 @@ class TextPaginationEngine {
|
|||
dirtyGlobalIndex: Int = 0
|
||||
): List<PageTextLayout> {
|
||||
val totalLen = globalText.length
|
||||
if (totalLen == 0) return listOf(
|
||||
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.paginate start textLen=$totalLen page=${pageWidthPx.richAndroidLogFloat()}x${pageHeightPx.richAndroidLogFloat()} " +
|
||||
"margin=${marginX.richAndroidLogFloat()},${marginY.richAndroidLogFloat()} prev=${previousLayouts.size} dirty=$dirtyGlobalIndex"
|
||||
)
|
||||
if (pageWidthPx <= 0 || pageHeightPx <= 0) return emptyList()
|
||||
|
||||
val validPages = if (dirtyGlobalIndex > 0 && previousLayouts.isNotEmpty()) {
|
||||
previousLayouts.takeWhile { it.globalEndIndex < dirtyGlobalIndex }
|
||||
} else {
|
||||
emptyList()
|
||||
if (totalLen == 0) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate empty -> p0:0-0")
|
||||
return listOf(
|
||||
PageTextLayout(0, AnnotatedString(""), 0, 0, pageHeightPx)
|
||||
)
|
||||
}
|
||||
if (pageWidthPx <= 0 || pageHeightPx <= 0) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate aborted invalid page size")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val startPageIndex = validPages.size
|
||||
val measurementStartIndex = validPages.lastOrNull()?.globalEndIndex ?: 0
|
||||
|
||||
if (measurementStartIndex >= totalLen) return validPages
|
||||
|
||||
val textToMeasure = globalText.subSequence(measurementStartIndex, totalLen)
|
||||
val fullString = textToMeasure.text
|
||||
|
||||
val editorWidth = (pageWidthPx - (marginX * 2)).coerceAtLeast(10f)
|
||||
val editorHeight = (pageHeightPx - (marginY * 2)).coerceAtLeast(10f)
|
||||
|
||||
val measureResult = textMeasurer.measure(
|
||||
text = textToMeasure,
|
||||
style = TextStyle(fontSize = 16.sp, color = Color.Black),
|
||||
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
|
||||
density = density
|
||||
)
|
||||
|
||||
val newPages = mutableListOf<PageTextLayout>()
|
||||
var currentPageIndex = startPageIndex
|
||||
var currentPageStartRel = 0
|
||||
var currentPageAccumulatedHeight = 0f
|
||||
var currentPageIndex = 0
|
||||
var segmentStart = 0
|
||||
val rawText = globalText.text
|
||||
|
||||
var currentLineIndex = 0
|
||||
val totalLines = measureResult.lineCount
|
||||
while (segmentStart < totalLen) {
|
||||
val breakIndex = rawText.indexOf(PAGE_BREAK_CHAR, startIndex = segmentStart)
|
||||
val hasExplicitBreak = breakIndex != -1
|
||||
val contentEnd = if (hasExplicitBreak) breakIndex else totalLen
|
||||
val segmentEnd = if (hasExplicitBreak) breakIndex + 1 else totalLen
|
||||
|
||||
Timber.tag("RichTextFlow").d("Pagination: Measuring ${fullString.length} chars from Global $measurementStartIndex. Lines: $totalLines")
|
||||
|
||||
while (currentLineIndex < totalLines) {
|
||||
val lineTop = measureResult.getLineTop(currentLineIndex)
|
||||
val lineBottom = measureResult.getLineBottom(currentLineIndex)
|
||||
val lineHeight = lineBottom - lineTop
|
||||
|
||||
val lineStartRel = measureResult.getLineStart(currentLineIndex)
|
||||
val lineEndRel = measureResult.getLineEnd(currentLineIndex)
|
||||
|
||||
val localStartOffset = (currentPageStartRel - lineStartRel).coerceAtLeast(0)
|
||||
|
||||
if (lineStartRel + localStartOffset >= lineEndRel && currentLineIndex < totalLines - 1) {
|
||||
currentLineIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
val safeEndRel = lineEndRel.coerceAtMost(fullString.length)
|
||||
val lineContent = fullString.substring(lineStartRel, safeEndRel)
|
||||
|
||||
val breakIndexInLine = lineContent.indexOf(PAGE_BREAK_CHAR, localStartOffset)
|
||||
val hasPageBreak = breakIndexInLine != -1
|
||||
|
||||
val isStartOfPage = (currentPageAccumulatedHeight == 0f)
|
||||
val willOverflow = !isStartOfPage && (currentPageAccumulatedHeight + lineHeight > editorHeight)
|
||||
|
||||
if (hasPageBreak) {
|
||||
val splitRelIndex = lineStartRel + breakIndexInLine + 1
|
||||
val globalStart = measurementStartIndex + currentPageStartRel
|
||||
val globalEnd = measurementStartIndex + splitRelIndex
|
||||
|
||||
Timber.tag("RichTextMigration").v("PaginationEngine: Found PAGE_BREAK_CHAR at relative ${breakIndexInLine}. Breaking Page $currentPageIndex at Global Index $globalEnd")
|
||||
|
||||
if (globalEnd > globalStart) {
|
||||
val visibleText = globalText.subSequence(globalStart, globalEnd)
|
||||
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
|
||||
currentPageIndex++
|
||||
}
|
||||
|
||||
currentPageStartRel = splitRelIndex
|
||||
currentPageAccumulatedHeight = 0f
|
||||
continue
|
||||
}
|
||||
else if (willOverflow) {
|
||||
val globalStart = measurementStartIndex + currentPageStartRel
|
||||
val globalEnd = measurementStartIndex + lineStartRel
|
||||
|
||||
if (globalEnd > globalStart) {
|
||||
val visibleText = globalText.subSequence(globalStart, globalEnd)
|
||||
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
|
||||
Timber.tag("RichTextFlow").v("Page $currentPageIndex Created (Overflow): $globalStart -> $globalEnd")
|
||||
currentPageIndex++
|
||||
}
|
||||
|
||||
currentPageStartRel = lineStartRel
|
||||
currentPageAccumulatedHeight = 0f
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
currentPageAccumulatedHeight += lineHeight
|
||||
currentLineIndex++
|
||||
currentPageIndex = newPages.appendMeasuredAndroidRichTextSegment(
|
||||
globalText = globalText,
|
||||
segmentStart = segmentStart,
|
||||
contentEnd = contentEnd,
|
||||
explicitBreakEnd = if (hasExplicitBreak) segmentEnd else null,
|
||||
pageIndex = currentPageIndex,
|
||||
pageHeightPx = pageHeightPx,
|
||||
editorWidth = editorWidth,
|
||||
editorHeight = editorHeight,
|
||||
textMeasurer = textMeasurer,
|
||||
density = density
|
||||
)
|
||||
segmentStart = segmentEnd
|
||||
}
|
||||
|
||||
if (currentPageStartRel < fullString.length) {
|
||||
val globalStart = measurementStartIndex + currentPageStartRel
|
||||
val globalEnd = measurementStartIndex + fullString.length
|
||||
val visibleText = globalText.subSequence(globalStart, globalEnd)
|
||||
|
||||
newPages.add(PageTextLayout(currentPageIndex, visibleText, globalStart, globalEnd, pageHeightPx))
|
||||
}
|
||||
|
||||
val resultLayouts = validPages + newPages
|
||||
val resultLayouts = newPages.withTrailingAndroidBlankRichTextPageIfNeeded(
|
||||
globalText = globalText,
|
||||
pageHeightPx = pageHeightPx
|
||||
)
|
||||
|
||||
val mapLog = resultLayouts.joinToString("\n") {
|
||||
" Page ${it.pageIndex}: Global[${it.globalStartIndex}..${it.globalEndIndex}]"
|
||||
}
|
||||
Timber.tag("RichTextMigration").i("Pagination Map Generated:\n$mapLog")
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.paginate done -> ${resultLayouts.richAndroidLayoutSummary()}")
|
||||
|
||||
return resultLayouts
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<PageTextLayout>.appendMeasuredAndroidRichTextSegment(
|
||||
globalText: AnnotatedString,
|
||||
segmentStart: Int,
|
||||
contentEnd: Int,
|
||||
explicitBreakEnd: Int?,
|
||||
pageIndex: Int,
|
||||
pageHeightPx: Float,
|
||||
editorWidth: Float,
|
||||
editorHeight: Float,
|
||||
textMeasurer: TextMeasurer,
|
||||
density: Density
|
||||
): Int {
|
||||
var nextPageIndex = pageIndex
|
||||
if (segmentStart >= contentEnd) {
|
||||
val breakEnd = explicitBreakEnd ?: return nextPageIndex
|
||||
add(
|
||||
PageTextLayout(
|
||||
pageIndex = nextPageIndex,
|
||||
visibleText = globalText.subSequence(segmentStart, breakEnd),
|
||||
globalStartIndex = segmentStart,
|
||||
globalEndIndex = breakEnd,
|
||||
pageHeightPx = pageHeightPx
|
||||
)
|
||||
)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.paginate pageBreakOnly page=$nextPageIndex global=$segmentStart..$breakEnd"
|
||||
)
|
||||
return nextPageIndex + 1
|
||||
}
|
||||
|
||||
val contentLength = contentEnd - segmentStart
|
||||
var relativeStart = 0
|
||||
while (relativeStart < contentLength) {
|
||||
val globalStart = segmentStart + relativeStart
|
||||
val remainingText = globalText.subSequence(globalStart, contentEnd)
|
||||
val measureResult = textMeasurer.measure(
|
||||
text = remainingText,
|
||||
style = TextStyle(fontSize = 16.sp, color = Color.Black),
|
||||
constraints = Constraints(maxWidth = editorWidth.toInt(), maxHeight = Constraints.Infinity),
|
||||
density = density
|
||||
)
|
||||
val fitsOnPage = measureResult.size.height.toFloat() <= editorHeight || measureResult.lineCount <= 1
|
||||
var overflowLineIndex: Int? = null
|
||||
val relativeEnd = if (fitsOnPage) {
|
||||
contentLength
|
||||
} else {
|
||||
val lineIndex = measureResult.richAndroidLastFittingLineIndex(editorHeight)
|
||||
overflowLineIndex = lineIndex
|
||||
val localEnd = measureResult.getLineEnd(lineIndex)
|
||||
.coerceIn(0, remainingText.length)
|
||||
.coerceAtLeast(1)
|
||||
(relativeStart + localEnd)
|
||||
.coerceAtLeast(relativeStart + 1)
|
||||
.coerceAtMost(contentLength)
|
||||
}
|
||||
val isLastContentPage = relativeEnd >= contentLength
|
||||
val globalEnd = if (isLastContentPage && explicitBreakEnd != null) {
|
||||
explicitBreakEnd
|
||||
} else {
|
||||
segmentStart + relativeEnd
|
||||
}
|
||||
|
||||
add(
|
||||
PageTextLayout(
|
||||
pageIndex = nextPageIndex,
|
||||
visibleText = globalText.subSequence(globalStart, globalEnd),
|
||||
globalStartIndex = globalStart,
|
||||
globalEndIndex = globalEnd,
|
||||
pageHeightPx = pageHeightPx
|
||||
)
|
||||
)
|
||||
if (isLastContentPage && explicitBreakEnd != null) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.paginate pageBreak page=$nextPageIndex global=$globalStart..$globalEnd"
|
||||
)
|
||||
} else if (!fitsOnPage) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.paginate overflow page=$nextPageIndex global=$globalStart..$globalEnd line=$overflowLineIndex"
|
||||
)
|
||||
}
|
||||
nextPageIndex++
|
||||
relativeStart = relativeEnd
|
||||
}
|
||||
|
||||
return nextPageIndex
|
||||
}
|
||||
|
||||
private fun TextLayoutResult.richAndroidLastFittingLineIndex(editorHeight: Float): Int {
|
||||
var lastFitting = 0
|
||||
for (lineIndex in 0 until lineCount) {
|
||||
if (lineIndex == 0 || getLineBottom(lineIndex) <= editorHeight) {
|
||||
lastFitting = lineIndex
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastFitting.coerceIn(0, (lineCount - 1).coerceAtLeast(0))
|
||||
}
|
||||
|
||||
private fun List<PageTextLayout>.withTrailingAndroidBlankRichTextPageIfNeeded(
|
||||
globalText: AnnotatedString,
|
||||
pageHeightPx: Float
|
||||
): List<PageTextLayout> {
|
||||
if (globalText.text.lastOrNull() != PAGE_BREAK_CHAR) return this
|
||||
val lastLayout = lastOrNull()
|
||||
val trailingStart = globalText.length
|
||||
if (lastLayout != null &&
|
||||
lastLayout.globalStartIndex == trailingStart &&
|
||||
lastLayout.globalEndIndex == trailingStart
|
||||
) {
|
||||
return this
|
||||
}
|
||||
return this + PageTextLayout(
|
||||
pageIndex = (lastLayout?.pageIndex ?: -1) + 1,
|
||||
visibleText = AnnotatedString(""),
|
||||
globalStartIndex = trailingStart,
|
||||
globalEndIndex = trailingStart,
|
||||
pageHeightPx = pageHeightPx
|
||||
)
|
||||
}
|
||||
|
||||
private fun AnnotatedString.withoutTrailingAndroidPageBreak(): AnnotatedString {
|
||||
return if (text.lastOrNull() == PAGE_BREAK_CHAR) {
|
||||
subSequence(0, length - 1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnnotatedString.withRestoredTrailingAndroidPageBreak(shouldRestore: Boolean): AnnotatedString {
|
||||
if (!shouldRestore) return this
|
||||
if (text.lastOrNull() == PAGE_BREAK_CHAR) return this
|
||||
return this + AnnotatedString(PAGE_BREAK_CHAR.toString())
|
||||
}
|
||||
|
||||
class PdfRichTextRepository(private val context: Context) {
|
||||
private val _document = MutableStateFlow<GlobalRichDocument?>(null)
|
||||
val document = _document.asStateFlow()
|
||||
|
|
@ -396,8 +475,12 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
suspend fun load(bookId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = getFile(bookId)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.repository.load start book=$bookId exists=${file.exists()} path=${file.absolutePath}"
|
||||
)
|
||||
if (!file.exists()) {
|
||||
_document.value = GlobalRichDocument("", emptyList())
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d("android.repository.load missing -> empty book=$bookId")
|
||||
return@withContext
|
||||
}
|
||||
try {
|
||||
|
|
@ -425,7 +508,11 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
)
|
||||
}
|
||||
_document.value = GlobalRichDocument(text, spans)
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.repository.load decoded book=$bookId rawLen=${jsonString.length} textLen=${text.length} spans=${spans.size}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.load failed book=$bookId")
|
||||
Timber.e(e, "Failed to load rich text doc")
|
||||
_document.value = GlobalRichDocument("", emptyList())
|
||||
}
|
||||
|
|
@ -436,6 +523,9 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
_document.value = document
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.repository.save start book=$bookId textLen=${document.text.length} spans=${document.spans.size}"
|
||||
)
|
||||
val obj = JSONObject().apply {
|
||||
put("text", document.text)
|
||||
val spansArray = JSONArray()
|
||||
|
|
@ -456,14 +546,35 @@ class PdfRichTextRepository(private val context: Context) {
|
|||
}
|
||||
put("spans", spansArray)
|
||||
}
|
||||
getFile(bookId).writeText(obj.toString())
|
||||
val file = getFile(bookId)
|
||||
file.writeText(obj.toString())
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).d(
|
||||
"android.repository.save done book=$bookId bytes=${file.length()} path=${file.absolutePath}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(SHARED_PDF_RICH_TEXT_LOG_TAG).e(e, "android.repository.save failed book=$bookId")
|
||||
Timber.e(e, "Failed to save rich text doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.richAndroidLogFloat(): String {
|
||||
return if (isFinite()) {
|
||||
val rounded = kotlin.math.round(this * 10f) / 10f
|
||||
rounded.toString()
|
||||
} else {
|
||||
toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<PageTextLayout>.richAndroidLayoutSummary(): String {
|
||||
if (isEmpty()) return "[]"
|
||||
return joinToString(prefix = "[", postfix = "]", limit = 8, truncated = "...") { layout ->
|
||||
"p${layout.pageIndex}:${layout.globalStartIndex}-${layout.globalEndIndex}/len${layout.visibleText.length}"
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class RichTextController(
|
||||
private val repository: PdfRichTextRepository,
|
||||
|
|
@ -485,6 +596,9 @@ class RichTextController(
|
|||
var pageLayouts by mutableStateOf(emptyList<PageTextLayout>())
|
||||
private set
|
||||
|
||||
val hasRenderableText: Boolean
|
||||
get() = globalTextFieldValue.text.hasRenderableRichText()
|
||||
|
||||
var currentStyle: SpanStyle by mutableStateOf(SpanStyle(color = Color.Black, fontSize = 16.sp))
|
||||
private set
|
||||
|
||||
|
|
@ -720,9 +834,11 @@ class RichTextController(
|
|||
val currentGlobal = globalTextFieldValue.annotatedString
|
||||
|
||||
// FIX: Strip ZWSP (index 0) from local text
|
||||
val localText = if (localTextFieldValue.annotatedString.isNotEmpty()) {
|
||||
val localEditableText = if (localTextFieldValue.annotatedString.isNotEmpty()) {
|
||||
localTextFieldValue.annotatedString.subSequence(1, localTextFieldValue.annotatedString.length)
|
||||
} else AnnotatedString("")
|
||||
val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR
|
||||
val localText = localEditableText.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak)
|
||||
|
||||
Timber.tag("RichTextFlow").d("Sync: Page $activePageIndex, GlobalRange [$globalStart..$globalEnd], LocalLen ${localText.length}")
|
||||
|
||||
|
|
@ -776,7 +892,7 @@ class RichTextController(
|
|||
activePageIndex = newActiveLayout.pageIndex
|
||||
val reExtractedText = newGlobalAnnotated.subSequence(
|
||||
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
|
||||
)
|
||||
).withoutTrailingAndroidPageBreak()
|
||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtractedText
|
||||
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1)
|
||||
.coerceIn(0, textWithZwsp.length)
|
||||
|
|
@ -865,28 +981,26 @@ class RichTextController(
|
|||
val editorWidth = (lastPageWidth - (margin * 2)).coerceAtLeast(10f)
|
||||
|
||||
val vText = currentLayout.visibleText
|
||||
val editableText = vText.withoutTrailingAndroidPageBreak()
|
||||
// FIX: Prepend ZWSP to the visible text
|
||||
val textWithZwsp = AnnotatedString(ZWSP) + vText
|
||||
val safeLen = if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR) vText.length - 1 else vText.length
|
||||
val textWithZwsp = AnnotatedString(ZWSP) + editableText
|
||||
val safeLen = editableText.length
|
||||
|
||||
// FIX: Adjust initial selection by +1 because of ZWSP
|
||||
localTextFieldValue = TextFieldValue(textWithZwsp, TextRange(safeLen + 1))
|
||||
|
||||
val measureResult = measurer.measure(
|
||||
text = currentLayout.visibleText, // We measure the original for layout tap calc
|
||||
text = editableText, // We measure editable text, not the hidden page-break sentinel
|
||||
style = TextStyle(fontSize = 16.sp, color = Color.Black),
|
||||
constraints = Constraints(maxWidth = editorWidth.toInt()),
|
||||
density = density
|
||||
)
|
||||
|
||||
val textHeight = measureResult.size.height.toFloat()
|
||||
val textHeight = if (editableText.isEmpty()) 0f else measureResult.size.height.toFloat()
|
||||
|
||||
if (localTapOffset.y <= textHeight) {
|
||||
if (editableText.isNotEmpty() && localTapOffset.y <= textHeight) {
|
||||
var localIndex = measureResult.getOffsetForPosition(localTapOffset)
|
||||
|
||||
if (vText.isNotEmpty() && vText.last() == PAGE_BREAK_CHAR && localIndex >= vText.length) {
|
||||
localIndex = vText.length - 1
|
||||
}
|
||||
localIndex = localIndex.coerceIn(0, editableText.length)
|
||||
localTextFieldValue = localTextFieldValue.copy(selection = TextRange(localIndex + 1))
|
||||
} else {
|
||||
val gap = localTapOffset.y - textHeight
|
||||
|
|
@ -1132,7 +1246,9 @@ class RichTextController(
|
|||
val currentGlobal = globalTextFieldValue.annotatedString
|
||||
|
||||
val localAnnotatedRaw = localTextFieldValue.annotatedString
|
||||
val localAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("")
|
||||
val localEditableAnnotated = if (localAnnotatedRaw.isNotEmpty()) localAnnotatedRaw.subSequence(1, localAnnotatedRaw.length) else AnnotatedString("")
|
||||
val shouldPreservePageBreak = layout.visibleText.text.lastOrNull() == PAGE_BREAK_CHAR
|
||||
val localAnnotated = localEditableAnnotated.withRestoredTrailingAndroidPageBreak(shouldPreservePageBreak)
|
||||
|
||||
val charBeforeSync = if (globalStart > 0) currentGlobal.text[globalStart - 1] else "START"
|
||||
val charAfterSync = if (globalEnd < currentGlobal.length) currentGlobal.text[globalEnd] else "END"
|
||||
|
|
@ -1197,7 +1313,7 @@ class RichTextController(
|
|||
|
||||
val reExtracted = newGlobalAnnotated.subSequence(
|
||||
newActiveLayout.globalStartIndex, newActiveLayout.globalEndIndex
|
||||
)
|
||||
).withoutTrailingAndroidPageBreak()
|
||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
||||
val newLocalCursor = (newGlobalCursorPos - newActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
||||
|
||||
|
|
@ -1301,7 +1417,7 @@ class RichTextController(
|
|||
activePageIndex = finalActiveLayout.pageIndex
|
||||
val reExtracted = intermediateGlobal.subSequence(
|
||||
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
|
||||
)
|
||||
).withoutTrailingAndroidPageBreak()
|
||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
||||
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
||||
|
||||
|
|
@ -1351,7 +1467,7 @@ class RichTextController(
|
|||
|
||||
val reExtracted = newGlobalText.subSequence(
|
||||
finalActiveLayout.globalStartIndex, finalActiveLayout.globalEndIndex
|
||||
)
|
||||
).withoutTrailingAndroidPageBreak()
|
||||
val textWithZwsp = AnnotatedString(ZWSP) + reExtracted
|
||||
val localCursor = (newCursorPos - finalActiveLayout.globalStartIndex + 1).coerceIn(0, textWithZwsp.length)
|
||||
|
||||
|
|
@ -1400,4 +1516,4 @@ class RichTextController(
|
|||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue