start tts from text select (#68)
* Added support for Text-to-Speech from selection in EPUB reader. * Added support for Text-to-Speech from selection in PDF reader
This commit is contained in:
parent
50af284224
commit
dece09fec0
8 changed files with 428 additions and 252 deletions
|
|
@ -1038,7 +1038,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
window.extractTextWithCfi = function () {
|
window.extractTextWithCfi = function () {
|
||||||
const results = [];
|
const results =[];
|
||||||
const contentNodes = document.body.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote");
|
const contentNodes = document.body.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote");
|
||||||
|
|
||||||
contentNodes.forEach((node) => {
|
contentNodes.forEach((node) => {
|
||||||
|
|
@ -1058,6 +1058,73 @@
|
||||||
return jsonResult;
|
return jsonResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.extractTextWithCfiFromSelection = function() {
|
||||||
|
try {
|
||||||
|
var selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.toString().trim() === "") {
|
||||||
|
return window.extractTextWithCfiFromTop(); // Fallback
|
||||||
|
}
|
||||||
|
var range = selection.getRangeAt(0);
|
||||||
|
var startNode = range.startContainer;
|
||||||
|
var startOffset = range.startOffset;
|
||||||
|
|
||||||
|
const ttsNodeSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote";
|
||||||
|
let startBlock = startNode.nodeType === Node.TEXT_NODE ? startNode.parentNode.closest(ttsNodeSelector) : startNode.closest(ttsNodeSelector);
|
||||||
|
|
||||||
|
if (!startBlock) return window.extractTextWithCfiFromTop(); // Fallback
|
||||||
|
|
||||||
|
let absoluteStartOffset = 0;
|
||||||
|
const treeWalker = document.createTreeWalker(startBlock, NodeFilter.SHOW_TEXT, null, false);
|
||||||
|
let currentNode = treeWalker.nextNode();
|
||||||
|
while (currentNode && currentNode !== startNode) {
|
||||||
|
absoluteStartOffset += currentNode.nodeValue.length;
|
||||||
|
currentNode = treeWalker.nextNode();
|
||||||
|
}
|
||||||
|
if (currentNode === startNode) {
|
||||||
|
absoluteStartOffset += startOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allContentNodes = Array.from(document.body.querySelectorAll(ttsNodeSelector));
|
||||||
|
const startIndex = allContentNodes.findIndex(node => node === startBlock);
|
||||||
|
|
||||||
|
if (startIndex === -1) return window.extractTextWithCfiFromTop();
|
||||||
|
|
||||||
|
const nodesToProcess = allContentNodes.slice(startIndex);
|
||||||
|
const results =[];
|
||||||
|
|
||||||
|
nodesToProcess.forEach((node, index) => {
|
||||||
|
let fullText = node.textContent || "";
|
||||||
|
if (fullText.trim().length > 0 && node.offsetParent !== null) {
|
||||||
|
try {
|
||||||
|
const cfiObj = getCfiPathForElement(node, 0);
|
||||||
|
if (cfiObj && cfiObj.cfi) {
|
||||||
|
if (index === 0) {
|
||||||
|
// Slice the very first block strictly from the selected character
|
||||||
|
let sliced = fullText.substring(absoluteStartOffset);
|
||||||
|
if (sliced.trim().length > 0) {
|
||||||
|
results.push({
|
||||||
|
cfi: cfiObj,
|
||||||
|
text: sliced,
|
||||||
|
startOffset: absoluteStartOffset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.push({
|
||||||
|
cfi: cfiObj,
|
||||||
|
text: fullText,
|
||||||
|
startOffset: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return JSON.stringify(results);
|
||||||
|
} catch(e) {
|
||||||
|
return window.extractTextWithCfiFromTop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.TtsBridgeHelper = {
|
window.TtsBridgeHelper = {
|
||||||
extractAndRelayText: function () {
|
extractAndRelayText: function () {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1072,6 +1139,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
extractAndRelayTextFromSelection: function() {
|
||||||
|
try {
|
||||||
|
const structuredTextJson = window.extractTextWithCfiFromSelection();
|
||||||
|
if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) {
|
||||||
|
TtsBridge.onStructuredTextExtracted(structuredTextJson);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) {
|
||||||
|
TtsBridge.onStructuredTextExtracted("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.reportTopChunk = function () {
|
window.reportTopChunk = function () {
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,8 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CopyAll
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -74,7 +69,6 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntRect
|
import androidx.compose.ui.unit.IntRect
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
|
@ -84,7 +78,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupPositionProvider
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.aryan.reader.R
|
import com.aryan.reader.paginatedreader.PaginatedTextSelectionMenu
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
@ -896,75 +890,69 @@ fun ChapterWebView(
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = {
|
PaginatedTextSelectionMenu(
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
onCopy = {
|
||||||
val clip = ClipData.newPlainText("Copied Text", state.selectedText)
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(clip)
|
val clip = ClipData.newPlainText("Copied Text", state.selectedText)
|
||||||
state.finishActionModeCallback()
|
clipboard.setPrimaryClip(clip)
|
||||||
localWebViewRef?.clearFocus()
|
state.finishActionModeCallback()
|
||||||
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
|
localWebViewRef?.clearFocus()
|
||||||
customMenuState = null
|
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
|
||||||
}) {
|
customMenuState = null
|
||||||
Icon(Icons.Default.CopyAll, contentDescription = "Copy")
|
},
|
||||||
}
|
onSelectAll = null,
|
||||||
|
onDictionary = {
|
||||||
if (state.selectedText.length <= 2000) {
|
|
||||||
IconButton(onClick = {
|
|
||||||
val textToDefine = state.selectedText
|
val textToDefine = state.selectedText
|
||||||
if (textToDefine.isNotBlank()) {
|
if (textToDefine.isNotBlank()) {
|
||||||
onWordSelectedForAiDefinition(textToDefine)
|
onWordSelectedForAiDefinition(textToDefine)
|
||||||
}
|
}
|
||||||
customMenuState = null
|
customMenuState = null
|
||||||
}) {
|
},
|
||||||
Icon(painterResource(id = R.drawable.dictionary), contentDescription = "Dictionary")
|
onTranslate = {
|
||||||
}
|
|
||||||
IconButton(onClick = {
|
|
||||||
val textToDefine = state.selectedText
|
val textToDefine = state.selectedText
|
||||||
if (textToDefine.isNotBlank()) {
|
if (textToDefine.isNotBlank()) {
|
||||||
onTranslate(textToDefine)
|
onTranslate(textToDefine)
|
||||||
}
|
}
|
||||||
customMenuState = null
|
customMenuState = null
|
||||||
}) {
|
},
|
||||||
Icon(painterResource(id = R.drawable.translate), contentDescription = "Translate")
|
onSearch = {
|
||||||
}
|
|
||||||
IconButton(onClick = {
|
|
||||||
val textToDefine = state.selectedText
|
val textToDefine = state.selectedText
|
||||||
if (textToDefine.isNotBlank()) {
|
if (textToDefine.isNotBlank()) {
|
||||||
onSearch(textToDefine)
|
onSearch(textToDefine)
|
||||||
}
|
}
|
||||||
customMenuState = null
|
customMenuState = null
|
||||||
}) {
|
},
|
||||||
Icon(painterResource(id = R.drawable.search), contentDescription = "Search")
|
onHighlight = null, // Highlight handles itself above in the Colors Row
|
||||||
}
|
onTts = {
|
||||||
}
|
localWebViewRef?.evaluateJavascript("javascript:window.TtsBridgeHelper.extractAndRelayTextFromSelection();", null)
|
||||||
|
|
||||||
if (state.isExistingHighlight && state.cfi != null) {
|
|
||||||
IconButton(onClick = {
|
|
||||||
Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'")
|
|
||||||
val highlightToDelete = userHighlights.find { h ->
|
|
||||||
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (highlightToDelete != null) {
|
|
||||||
val cssClassToDelete = highlightToDelete.color.cssClass
|
|
||||||
val allCfiParts = highlightToDelete.cfi.split("|")
|
|
||||||
|
|
||||||
allCfiParts.forEach { partCfi ->
|
|
||||||
localWebViewRef?.evaluateJavascript(
|
|
||||||
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
onHighlightDeleted(highlightToDelete.cfi)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.finishActionModeCallback()
|
state.finishActionModeCallback()
|
||||||
|
localWebViewRef?.clearFocus()
|
||||||
|
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
|
||||||
customMenuState = null
|
customMenuState = null
|
||||||
}) {
|
},
|
||||||
Icon(Icons.Default.Delete, contentDescription = "Remove", tint = MaterialTheme.colorScheme.error)
|
onDelete = if (state.isExistingHighlight && state.cfi != null) {
|
||||||
}
|
{
|
||||||
}
|
val highlightToDelete = userHighlights.find { h ->
|
||||||
|
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
|
||||||
|
}
|
||||||
|
if (highlightToDelete != null) {
|
||||||
|
val cssClassToDelete = highlightToDelete.color.cssClass
|
||||||
|
val allCfiParts = highlightToDelete.cfi.split("|")
|
||||||
|
allCfiParts.forEach { partCfi ->
|
||||||
|
localWebViewRef?.evaluateJavascript(
|
||||||
|
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onHighlightDeleted(highlightToDelete.cfi)
|
||||||
|
}
|
||||||
|
state.finishActionModeCallback()
|
||||||
|
customMenuState = null
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
isProUser = isProUser,
|
||||||
|
isOss = isOss
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -956,6 +956,21 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
||||||
|
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val currentChapterInPaginatedMode by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (currentRenderMode == RenderMode.PAGINATED) {
|
||||||
|
(paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun startTts() {
|
fun startTts() {
|
||||||
if (isAutoScrollModeActive) {
|
if (isAutoScrollModeActive) {
|
||||||
isAutoScrollModeActive = false
|
isAutoScrollModeActive = false
|
||||||
|
|
@ -1007,10 +1022,66 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
fun startTtsFromSelectionPaginated(baseCfi: String, startOffset: Int) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val action = {
|
||||||
var showTtsSettingsSheet by remember { mutableStateOf(false) }
|
scope.launch {
|
||||||
var showDeviceVoiceSettingsSheet by remember { mutableStateOf(false) }
|
val bookPaginator = paginator as? BookPaginator
|
||||||
|
val chapterIndex = currentChapterInPaginatedMode ?: return@launch
|
||||||
|
val chunks = bookPaginator?.getTtsChunksForChapter(chapterIndex) ?: return@launch
|
||||||
|
|
||||||
|
var foundIdx = -1
|
||||||
|
for (i in chunks.indices) {
|
||||||
|
val c = chunks[i]
|
||||||
|
val cPath = c.sourceCfi.substringBefore(":")
|
||||||
|
val bPath = baseCfi.substringBefore(":")
|
||||||
|
if (cPath == bPath && startOffset >= c.startOffsetInSource && startOffset < c.startOffsetInSource + c.text.length) {
|
||||||
|
foundIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundIdx != -1) {
|
||||||
|
val target = chunks[foundIdx]
|
||||||
|
val relativeOffset = startOffset - target.startOffsetInSource
|
||||||
|
val safeRelativeOffset = relativeOffset.coerceIn(0, target.text.length)
|
||||||
|
val slicedText = target.text.substring(safeRelativeOffset)
|
||||||
|
val newChunk = target.copy(text = slicedText, startOffsetInSource = startOffset)
|
||||||
|
|
||||||
|
val remainingChunks = mutableListOf(newChunk)
|
||||||
|
remainingChunks.addAll(chunks.subList(foundIdx + 1, chunks.size))
|
||||||
|
|
||||||
|
if (remainingChunks.isNotEmpty()) {
|
||||||
|
ttsShouldStartOnChapterLoad = false
|
||||||
|
ttsChapterIndex = chapterIndex
|
||||||
|
val chapterTitle = chapters.getOrNull(chapterIndex)?.title
|
||||||
|
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
|
||||||
|
ttsController.start(
|
||||||
|
chunks = remainingChunks,
|
||||||
|
bookTitle = epubBook.title,
|
||||||
|
chapterTitle = chapterTitle,
|
||||||
|
coverImageUri = coverUriString,
|
||||||
|
ttsMode = currentTtsMode,
|
||||||
|
playbackSource = "READER"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAutoScrollModeActive) {
|
||||||
|
isAutoScrollModeActive = false
|
||||||
|
isAutoScrollPlaying = false
|
||||||
|
}
|
||||||
|
userStoppedTts = false
|
||||||
|
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
action()
|
||||||
|
} else if (activity?.shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) == true) {
|
||||||
|
showPermissionRationaleDialog = true
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TtsSessionObserver(
|
TtsSessionObserver(
|
||||||
ttsState = ttsState,
|
ttsState = ttsState,
|
||||||
|
|
@ -1314,16 +1385,6 @@ fun EpubReaderHost(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentChapterInPaginatedMode by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
if (currentRenderMode == RenderMode.PAGINATED) {
|
|
||||||
(paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(paginatedPagerState.currentPage, paginator, currentRenderMode) {
|
LaunchedEffect(paginatedPagerState.currentPage, paginator, currentRenderMode) {
|
||||||
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
|
if (currentRenderMode == RenderMode.PAGINATED && paginator != null && isPagerInitialized) {
|
||||||
val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
|
val chapterIndex = (paginator as? BookPaginator)?.findChapterIndexForPage(paginatedPagerState.currentPage)
|
||||||
|
|
@ -2250,10 +2311,11 @@ fun EpubReaderHost(
|
||||||
val cfiJsonObject =
|
val cfiJsonObject =
|
||||||
JSONObject(cfiJsonString)
|
JSONObject(cfiJsonString)
|
||||||
val cfi = cfiJsonObject.getString("cfi")
|
val cfi = cfiJsonObject.getString("cfi")
|
||||||
|
val baseOffset = jsonObject.optInt("startOffset", 0)
|
||||||
|
|
||||||
val subChunks =
|
val subChunks =
|
||||||
splitTextIntoChunks(text)
|
splitTextIntoChunks(text)
|
||||||
var currentOffset = 0
|
var currentOffset = baseOffset
|
||||||
for (subChunk in subChunks) {
|
for (subChunk in subChunks) {
|
||||||
ttsChunks.add(
|
ttsChunks.add(
|
||||||
TtsChunk(
|
TtsChunk(
|
||||||
|
|
@ -2649,6 +2711,9 @@ fun EpubReaderHost(
|
||||||
onSearch = { text ->
|
onSearch = { text ->
|
||||||
onSearchLookup(text)
|
onSearchLookup(text)
|
||||||
},
|
},
|
||||||
|
onStartTtsFromSelection = { cfi, offset ->
|
||||||
|
startTtsFromSelectionPaginated(cfi, offset)
|
||||||
|
},
|
||||||
userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) },
|
userHighlights = userHighlights.filter { it.chapterIndex == (currentChapterInPaginatedMode ?: -1) },
|
||||||
onHighlightCreated = { cfi, text, colorId ->
|
onHighlightCreated = { cfi, text, colorId ->
|
||||||
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
|
Timber.d("EpubReaderScreen: onHighlightCreated. CFI: $cfi")
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,6 @@ import android.widget.Toast
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.ui.text.PlatformTextStyle
|
|
||||||
import androidx.compose.ui.text.style.LineHeightStyle
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
@ -64,12 +62,12 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -125,6 +123,7 @@ import androidx.compose.ui.platform.TextToolbar
|
||||||
import androidx.compose.ui.platform.TextToolbarStatus
|
import androidx.compose.ui.platform.TextToolbarStatus
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.PlatformTextStyle
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
|
@ -135,6 +134,7 @@ import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.LineBreak
|
import androidx.compose.ui.text.style.LineBreak
|
||||||
|
import androidx.compose.ui.text.style.LineHeightStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextIndent
|
import androidx.compose.ui.text.style.TextIndent
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
|
@ -508,6 +508,7 @@ fun PaginatedReaderScreen(
|
||||||
onWordSelectedForAiDefinition: (String) -> Unit,
|
onWordSelectedForAiDefinition: (String) -> Unit,
|
||||||
onTranslate: (String) -> Unit,
|
onTranslate: (String) -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
|
onStartTtsFromSelection: (String, Int) -> Unit,
|
||||||
userHighlights: List<UserHighlight>,
|
userHighlights: List<UserHighlight>,
|
||||||
onHighlightCreated: (String, String, String) -> Unit,
|
onHighlightCreated: (String, String, String) -> Unit,
|
||||||
onHighlightDeleted: (String) -> Unit,
|
onHighlightDeleted: (String) -> Unit,
|
||||||
|
|
@ -807,6 +808,7 @@ fun PaginatedReaderScreen(
|
||||||
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
|
onWordSelectedForAiDefinition = onWordSelectedForAiDefinition,
|
||||||
onTranslate = onTranslate,
|
onTranslate = onTranslate,
|
||||||
onSearch = onSearch,
|
onSearch = onSearch,
|
||||||
|
onStartTtsFromSelection = onStartTtsFromSelection,
|
||||||
userHighlights = userHighlights,
|
userHighlights = userHighlights,
|
||||||
onHighlightCreated = onHighlightCreated,
|
onHighlightCreated = onHighlightCreated,
|
||||||
onHighlightDeleted = onHighlightDeleted,
|
onHighlightDeleted = onHighlightDeleted,
|
||||||
|
|
@ -1379,6 +1381,7 @@ internal fun PaginatedReaderContent(
|
||||||
onWordSelectedForAiDefinition: (String) -> Unit,
|
onWordSelectedForAiDefinition: (String) -> Unit,
|
||||||
onTranslate: (String) -> Unit,
|
onTranslate: (String) -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
|
onStartTtsFromSelection: (String, Int) -> Unit,
|
||||||
onGetChapterInfo: (Int) -> Pair<String, Int?>?,
|
onGetChapterInfo: (Int) -> Pair<String, Int?>?,
|
||||||
userHighlights: List<UserHighlight>,
|
userHighlights: List<UserHighlight>,
|
||||||
onHighlightCreated: (String, String, String) -> Unit,
|
onHighlightCreated: (String, String, String) -> Unit,
|
||||||
|
|
@ -2533,14 +2536,58 @@ internal fun PaginatedReaderContent(
|
||||||
}, onSelectAll = {
|
}, onSelectAll = {
|
||||||
state.onSelectAll?.invoke()
|
state.onSelectAll?.invoke()
|
||||||
state.onHide()
|
state.onHide()
|
||||||
|
}, onTts = {
|
||||||
|
isForHighlight = true
|
||||||
|
state.onCopy()
|
||||||
|
isForHighlight = false
|
||||||
|
|
||||||
|
capturedTextForAction?.let { text ->
|
||||||
|
val selectionRect = state.rect
|
||||||
|
var geometricSuccess = false
|
||||||
|
val candidates = blockLayoutMap.filter { (_, triple) ->
|
||||||
|
val (_, coords, _) = triple
|
||||||
|
if (!coords.isAttached) return@filter false
|
||||||
|
val pos = coords.positionInWindow()
|
||||||
|
val size = coords.size.toSize()
|
||||||
|
Rect(pos, size).overlaps(selectionRect)
|
||||||
|
}
|
||||||
|
if (candidates.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val sorted = candidates.entries.sortedBy { it.value.second.positionInWindow().y }
|
||||||
|
val firstEntry = sorted.first()
|
||||||
|
val startCfi: String = firstEntry.key
|
||||||
|
val startTriple = firstEntry.value
|
||||||
|
|
||||||
|
val startLayout: TextLayoutResult = startTriple.first
|
||||||
|
val startCoords: LayoutCoordinates = startTriple.second
|
||||||
|
val startAbsOffset: Int = startTriple.third
|
||||||
|
|
||||||
|
val localStart = startCoords.windowToLocal(selectionRect.topLeft)
|
||||||
|
val finalStartOffset = startLayout.getOffsetForPosition(localStart)
|
||||||
|
val absStart: Int = finalStartOffset + startAbsOffset
|
||||||
|
|
||||||
|
onStartTtsFromSelection(startCfi, absStart)
|
||||||
|
geometricSuccess = true
|
||||||
|
} catch(e: Exception) {
|
||||||
|
Timber.e(e, "TTS Selection error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!geometricSuccess) {
|
||||||
|
val pageInfo = onGetPage(pagerState.currentPage)
|
||||||
|
val firstCfi = pageInfo?.content?.firstOrNull { it.cfi != null }?.cfi
|
||||||
|
if (firstCfi != null) {
|
||||||
|
onStartTtsFromSelection(firstCfi, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.onHide()
|
||||||
}, onDictionary = {
|
}, onDictionary = {
|
||||||
isForDictionary = true
|
isForDictionary = true
|
||||||
state.onCopy()
|
state.onCopy()
|
||||||
isForDictionary = false
|
isForDictionary = false
|
||||||
state.onHide()
|
state.onHide()
|
||||||
}, onTranslate = {
|
}, onTranslate = {
|
||||||
state.onCopy() // we don't necessarily need copy to get text, but follow dictionary pattern if needed, wait menuState has selectedText!
|
state.onCopy()
|
||||||
// Actually PaginatedMenuState has `selectedText`? Let's check.
|
|
||||||
onTranslate(capturedTextForAction ?: "")
|
onTranslate(capturedTextForAction ?: "")
|
||||||
state.onHide()
|
state.onHide()
|
||||||
}, onSearch = {
|
}, onSearch = {
|
||||||
|
|
@ -2789,6 +2836,9 @@ internal fun PaginatedReaderContent(
|
||||||
}, onSearch = {
|
}, onSearch = {
|
||||||
onSearch(sel.text)
|
onSearch(sel.text)
|
||||||
activeSelection = null
|
activeSelection = null
|
||||||
|
}, onTts = {
|
||||||
|
onStartTtsFromSelection(sel.baseCfi, sel.startOffset)
|
||||||
|
activeSelection = null
|
||||||
}, onHighlight = { color ->
|
}, onHighlight = { color ->
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}"
|
"CustomSelection: Highlight clicked. Text: '${sel.text}', BaseCFI: ${sel.baseCfi}, StartOffset: ${sel.startOffset}"
|
||||||
|
|
@ -2863,6 +2913,13 @@ internal fun PaginatedReaderContent(
|
||||||
onSearch(highlight.text)
|
onSearch(highlight.text)
|
||||||
activeHighlightForMenu = null
|
activeHighlightForMenu = null
|
||||||
},
|
},
|
||||||
|
onTts = {
|
||||||
|
val firstPart = highlight.cfi.split("|").first()
|
||||||
|
val baseCfi = firstPart.substringBefore(":")
|
||||||
|
val offset = firstPart.substringAfter(":", "0").toIntOrNull() ?: 0
|
||||||
|
onStartTtsFromSelection(baseCfi, offset)
|
||||||
|
activeHighlightForMenu = null
|
||||||
|
},
|
||||||
onHighlight = { color ->
|
onHighlight = { color ->
|
||||||
Timber.d("Menu: Updating highlight color to ${color.id}")
|
Timber.d("Menu: Updating highlight color to ${color.id}")
|
||||||
onHighlightDeleted(highlight.cfi)
|
onHighlightDeleted(highlight.cfi)
|
||||||
|
|
@ -2935,8 +2992,16 @@ private fun ChapterLoadingPlaceholder(title: String?) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class MenuActionItem(
|
||||||
|
val iconRes: Int? = null,
|
||||||
|
val imageVector: androidx.compose.ui.graphics.vector.ImageVector? = null,
|
||||||
|
val label: String,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
val isError: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PaginatedTextSelectionMenu(
|
fun PaginatedTextSelectionMenu(
|
||||||
onCopy: () -> Unit,
|
onCopy: () -> Unit,
|
||||||
onSelectAll: (() -> Unit)?,
|
onSelectAll: (() -> Unit)?,
|
||||||
onDictionary: () -> Unit,
|
onDictionary: () -> Unit,
|
||||||
|
|
@ -2944,6 +3009,7 @@ private fun PaginatedTextSelectionMenu(
|
||||||
onSearch: () -> Unit,
|
onSearch: () -> Unit,
|
||||||
onHighlight: ((HighlightColor) -> Unit)?,
|
onHighlight: ((HighlightColor) -> Unit)?,
|
||||||
onDelete: (() -> Unit)?,
|
onDelete: (() -> Unit)?,
|
||||||
|
onTts: (() -> Unit)?,
|
||||||
@Suppress("unused") isProUser: Boolean,
|
@Suppress("unused") isProUser: Boolean,
|
||||||
@Suppress("unused") isOss: Boolean,
|
@Suppress("unused") isOss: Boolean,
|
||||||
activeHighlightPalette: List<HighlightColor> = emptyList(),
|
activeHighlightPalette: List<HighlightColor> = emptyList(),
|
||||||
|
|
@ -2986,69 +3052,52 @@ private fun PaginatedTextSelectionMenu(
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Action Icons Row (Horizontal)
|
val actions = mutableListOf<MenuActionItem>()
|
||||||
Row(
|
actions.add(MenuActionItem(iconRes = R.drawable.copy, label = "Copy", onClick = onCopy))
|
||||||
modifier = Modifier
|
if (onTts != null) {
|
||||||
.fillMaxWidth()
|
actions.add(MenuActionItem(imageVector = Icons.AutoMirrored.Filled.VolumeUp, label = "Speak", onClick = onTts))
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
}
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
actions.add(MenuActionItem(iconRes = R.drawable.dictionary, label = "Dict", onClick = onDictionary))
|
||||||
verticalAlignment = Alignment.CenterVertically
|
actions.add(MenuActionItem(iconRes = R.drawable.translate, label = "Translate", onClick = onTranslate))
|
||||||
) {
|
actions.add(MenuActionItem(iconRes = R.drawable.search, label = "Search", onClick = onSearch))
|
||||||
IconButton(onClick = onCopy) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.copy),
|
|
||||||
contentDescription = "Copy",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = onDictionary) {
|
if (onSelectAll != null) {
|
||||||
Icon(
|
actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = onSelectAll))
|
||||||
painter = painterResource(id = R.drawable.dictionary),
|
}
|
||||||
contentDescription = "Dictionary",
|
if (onDelete != null) {
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
actions.add(MenuActionItem(imageVector = Icons.Default.Delete, label = "Remove", onClick = onDelete, isError = true))
|
||||||
modifier = Modifier.size(24.dp)
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = onTranslate) {
|
Column(modifier = Modifier.padding(bottom = 4.dp)) {
|
||||||
Icon(
|
actions.chunked(3).forEach { rowActions ->
|
||||||
painter = painterResource(id = R.drawable.translate),
|
Row(
|
||||||
contentDescription = "Translate",
|
modifier = Modifier
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
.fillMaxWidth()
|
||||||
modifier = Modifier.size(24.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
)
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
}
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
IconButton(onClick = onSearch) {
|
rowActions.forEach { action ->
|
||||||
Icon(
|
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||||
painter = painterResource(id = R.drawable.search),
|
Column(
|
||||||
contentDescription = "Search",
|
modifier = Modifier
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
.width(64.dp)
|
||||||
modifier = Modifier.size(24.dp)
|
.clickable { action.onClick() }
|
||||||
)
|
.padding(vertical = 8.dp),
|
||||||
}
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
if (onSelectAll != null) {
|
if (action.imageVector != null) {
|
||||||
IconButton(onClick = onSelectAll) {
|
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
Icon(
|
} else if (action.iconRes != null) {
|
||||||
painter = painterResource(id = R.drawable.select_all),
|
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
contentDescription = "Select All",
|
}
|
||||||
tint = MaterialTheme.colorScheme.onSurface,
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
modifier = Modifier.size(24.dp)
|
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
repeat(3 - rowActions.size) {
|
||||||
|
Spacer(modifier = Modifier.width(64.dp))
|
||||||
if (onDelete != null) {
|
}
|
||||||
IconButton(onClick = onDelete) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Delete,
|
|
||||||
contentDescription = "Remove",
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.CopyAll
|
import androidx.compose.material.icons.filled.CopyAll
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
|
@ -58,6 +59,7 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -88,6 +90,14 @@ internal data class OcrSymbolInfo(
|
||||||
val parentLine: OcrLine
|
val parentLine: OcrLine
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private class MenuActionItem(
|
||||||
|
val iconRes: Int? = null,
|
||||||
|
val imageVector: ImageVector? = null,
|
||||||
|
val label: String,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
val isError: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
enum class PdfHighlightColor(val color: Color) {
|
enum class PdfHighlightColor(val color: Color) {
|
||||||
YELLOW(Color(0xFFFBC02D)),
|
YELLOW(Color(0xFFFBC02D)),
|
||||||
GREEN(Color(0xFF388E3C)),
|
GREEN(Color(0xFF388E3C)),
|
||||||
|
|
@ -194,7 +204,8 @@ internal fun PdfSelectionMenuPopup(
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onSelectAll: () -> Unit,
|
onSelectAll: () -> Unit,
|
||||||
onColorSelected: (PdfHighlightColor) -> Unit,
|
onColorSelected: (PdfHighlightColor) -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit,
|
||||||
|
onTts: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
Popup(
|
Popup(
|
||||||
popupPositionProvider = popupPositionProvider,
|
popupPositionProvider = popupPositionProvider,
|
||||||
|
|
@ -266,93 +277,54 @@ internal fun PdfSelectionMenuPopup(
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
if (menuState.isExistingHighlight) {
|
val actions = mutableListOf<MenuActionItem>()
|
||||||
Row(modifier = Modifier.fillMaxWidth().clickable { onDelete() }
|
actions.add(MenuActionItem(iconRes = R.drawable.copy, label = "Copy", onClick = { onCopy(menuState.selectedText) }))
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
if (onTts != null) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
actions.add(MenuActionItem(imageVector = Icons.AutoMirrored.Filled.VolumeUp, label = "Speak", onClick = onTts))
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
}
|
||||||
Icon(
|
if (menuState.selectedText.length <= 2000) {
|
||||||
imageVector = Icons.Default.Delete,
|
actions.add(MenuActionItem(iconRes = R.drawable.dictionary, label = "Dict", onClick = { onAiDefine(menuState.selectedText) }))
|
||||||
contentDescription = "Remove",
|
actions.add(MenuActionItem(iconRes = R.drawable.translate, label = "Translate", onClick = { onTranslate(menuState.selectedText) }))
|
||||||
tint = MaterialTheme.colorScheme.error,
|
actions.add(MenuActionItem(imageVector = Icons.Default.Search, label = "Search", onClick = { onSearch(menuState.selectedText) }))
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Remove",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
}
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp) // Fixed height for the sleek row
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Copy
|
|
||||||
androidx.compose.material3.IconButton(
|
|
||||||
onClick = { onCopy(menuState.selectedText) }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CopyAll,
|
|
||||||
contentDescription = "Copy",
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dictionary
|
if (!menuState.isExistingHighlight) {
|
||||||
if (menuState.selectedText.length <= 2000) {
|
actions.add(MenuActionItem(iconRes = R.drawable.select_all, label = "Select All", onClick = { onSelectAll() }))
|
||||||
androidx.compose.material3.IconButton(
|
}
|
||||||
onClick = { onAiDefine(menuState.selectedText) }
|
if (menuState.isExistingHighlight) {
|
||||||
) {
|
actions.add(MenuActionItem(imageVector = Icons.Default.Delete, label = "Remove", onClick = { onDelete() }, isError = true))
|
||||||
Icon(
|
}
|
||||||
painter = painterResource(id = R.drawable.dictionary),
|
|
||||||
contentDescription = "Dictionary",
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Translate
|
Column(modifier = Modifier.padding(bottom = 4.dp)) {
|
||||||
if (menuState.selectedText.length <= 2000) {
|
actions.chunked(3).forEach { rowActions ->
|
||||||
androidx.compose.material3.IconButton(
|
Row(
|
||||||
onClick = { onTranslate(menuState.selectedText) }
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
rowActions.forEach { action ->
|
||||||
painter = painterResource(id = R.drawable.translate),
|
val tint = if (action.isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
|
||||||
contentDescription = "Translate",
|
Column(
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier
|
||||||
)
|
.width(64.dp)
|
||||||
}
|
.clickable { action.onClick() }
|
||||||
}
|
.padding(vertical = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
// Search
|
) {
|
||||||
if (menuState.selectedText.length <= 2000) {
|
if (action.imageVector != null) {
|
||||||
androidx.compose.material3.IconButton(
|
Icon(imageVector = action.imageVector, contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
onClick = { onSearch(menuState.selectedText) }
|
} else if (action.iconRes != null) {
|
||||||
) {
|
Icon(painter = painterResource(id = action.iconRes), contentDescription = action.label, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
Icon(
|
}
|
||||||
imageVector = Icons.Default.Search,
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
contentDescription = "Search",
|
Text(text = action.label, style = MaterialTheme.typography.labelSmall, color = tint, maxLines = 1)
|
||||||
modifier = Modifier.size(24.dp)
|
}
|
||||||
)
|
}
|
||||||
}
|
repeat(3 - rowActions.size) {
|
||||||
}
|
Spacer(modifier = Modifier.width(64.dp))
|
||||||
|
}
|
||||||
// Select All
|
|
||||||
if (!menuState.isExistingHighlight) {
|
|
||||||
androidx.compose.material3.IconButton(
|
|
||||||
onClick = { onSelectAll() }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.select_all),
|
|
||||||
contentDescription = "Select All",
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,7 @@ internal fun PdfPageComposable(
|
||||||
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
||||||
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
||||||
onHighlightDelete: (String) -> Unit = {},
|
onHighlightDelete: (String) -> Unit = {},
|
||||||
|
onTts: (Int, Int) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
|
SideEffect { Timber.tag("PdfDrawPerf").v("PdfPageComposable Recompose: Page $pageIndex") }
|
||||||
val pdfDocumentItem = pdfDocument.item
|
val pdfDocumentItem = pdfDocument.item
|
||||||
|
|
@ -2192,7 +2193,7 @@ internal fun PdfPageComposable(
|
||||||
customMenuState = CustomPdfMenuState(
|
customMenuState = CustomPdfMenuState(
|
||||||
selectedText = selectedText,
|
selectedText = selectedText,
|
||||||
anchorRect = combinedRect,
|
anchorRect = combinedRect,
|
||||||
charRange = Pair(-1, -1)
|
charRange = Pair(indices.first, indices.second)
|
||||||
)
|
)
|
||||||
Timber.d(
|
Timber.d(
|
||||||
"Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}"
|
"Menu shown after OCR drag. Anchor: ${customMenuState?.anchorRect}"
|
||||||
|
|
@ -2395,7 +2396,7 @@ internal fun PdfPageComposable(
|
||||||
selectedText = foundElement.text,
|
selectedText = foundElement.text,
|
||||||
anchorRect = combinedRect,
|
anchorRect = combinedRect,
|
||||||
charRange = Pair(
|
charRange = Pair(
|
||||||
-1, -1
|
symbolStartIndex, symbolEndIndex
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Timber.d(
|
Timber.d(
|
||||||
|
|
@ -3637,6 +3638,7 @@ internal fun PdfPageComposable(
|
||||||
onHighlightAdd = onHighlightAdd,
|
onHighlightAdd = onHighlightAdd,
|
||||||
onHighlightUpdate = onHighlightUpdate,
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
onHighlightDelete = onHighlightDelete,
|
onHighlightDelete = onHighlightDelete,
|
||||||
|
onTts = onTts,
|
||||||
teardropHeightPx = teardropHeightPxState.value,
|
teardropHeightPx = teardropHeightPxState.value,
|
||||||
activeDraggingHandle = activeDraggingHandle,
|
activeDraggingHandle = activeDraggingHandle,
|
||||||
showMagnifier = showMagnifier,
|
showMagnifier = showMagnifier,
|
||||||
|
|
@ -4587,6 +4589,7 @@ private fun PdfPageRenderer(
|
||||||
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit,
|
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit,
|
||||||
onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
|
onHighlightUpdate: (String, PdfHighlightColor) -> Unit,
|
||||||
onHighlightDelete: (String) -> Unit,
|
onHighlightDelete: (String) -> Unit,
|
||||||
|
onTts: (Int, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}")
|
Timber.tag("PdfPerf").v("PAGE_RENDERER: Recomposing Page ${selectionData.pageIndex}. DraggingHandle=${activeDraggingHandle != null}")
|
||||||
|
|
@ -4989,6 +4992,10 @@ private fun PdfPageRenderer(
|
||||||
onHighlightDelete(menuState.highlightId)
|
onHighlightDelete(menuState.highlightId)
|
||||||
}
|
}
|
||||||
onMenuDismiss()
|
onMenuDismiss()
|
||||||
|
},
|
||||||
|
onTts = {
|
||||||
|
onTts(selectionData.pageIndex, menuState.charRange.first)
|
||||||
|
onMenuDismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ internal fun PdfVerticalReader(
|
||||||
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
onHighlightAdd: (Int, Pair<Int, Int>, String, PdfHighlightColor) -> Unit = { _,_,_,_ -> },
|
||||||
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
onHighlightUpdate: (String, PdfHighlightColor) -> Unit = { _,_ -> },
|
||||||
onHighlightDelete: (String) -> Unit = {},
|
onHighlightDelete: (String) -> Unit = {},
|
||||||
|
onTts: (Int, Int) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
|
||||||
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
@ -1501,6 +1502,7 @@ internal fun PdfVerticalReader(
|
||||||
onHighlightAdd = onHighlightAdd,
|
onHighlightAdd = onHighlightAdd,
|
||||||
onHighlightUpdate = onHighlightUpdate,
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
onHighlightDelete = onHighlightDelete,
|
onHighlightDelete = onHighlightDelete,
|
||||||
|
onTts = onTts,
|
||||||
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
|
onTextBoxDragStart = { box, localTopLeft, touchOffset ->
|
||||||
val currentZoom = zoomAnimatable.value
|
val currentZoom = zoomAnimatable.value
|
||||||
val panX = panXAnimatable.value
|
val panX = panXAnimatable.value
|
||||||
|
|
|
||||||
|
|
@ -2398,8 +2398,8 @@ fun PdfViewerScreen(
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startTts(pageToReadOverride: Int? = null) {
|
fun startTts(pageToReadOverride: Int? = null, startCharIndex: Int? = null) {
|
||||||
Timber.d("TTS button clicked: Starting TTS for current page")
|
Timber.d("TTS button clicked: Starting TTS for current page/selection")
|
||||||
if (pdfDocument == null || totalPages == 0) {
|
if (pdfDocument == null || totalPages == 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2532,7 +2532,15 @@ fun PdfViewerScreen(
|
||||||
val processedText = preprocessTextForTts(rawPageText!!)
|
val processedText = preprocessTextForTts(rawPageText!!)
|
||||||
ttsPageData = TtsPageData(pageToRead, processedText, ocrUsedForCurrentPageTts)
|
ttsPageData = TtsPageData(pageToRead, processedText, ocrUsedForCurrentPageTts)
|
||||||
|
|
||||||
val chunks = splitTextIntoChunks(processedText.cleanText)
|
val cleanStartIndex = if (startCharIndex != null && startCharIndex >= 0) {
|
||||||
|
processedText.indexMap.indexOfFirst { it >= startCharIndex }.coerceAtLeast(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
val textToChunk = processedText.cleanText.substring(cleanStartIndex)
|
||||||
|
|
||||||
|
val chunks = splitTextIntoChunks(textToChunk)
|
||||||
|
|
||||||
val bookTitle = pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
|
val bookTitle = pdfDocument?.getDocumentMeta()?.title?.takeIf { it.isNotBlank() }
|
||||||
?: pdfUri.lastPathSegment ?: "PDF Document"
|
?: pdfUri.lastPathSegment ?: "PDF Document"
|
||||||
|
|
@ -2570,6 +2578,30 @@ fun PdfViewerScreen(
|
||||||
|
|
||||||
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
var showPermissionRationaleDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val startTtsWithPermissionCheck: (Int?, Int?) -> Unit = remember(context, activity, executeWithOcrCheck) {
|
||||||
|
{ pageOverride, startCharIndex ->
|
||||||
|
executeWithOcrCheck {
|
||||||
|
when {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context, Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED -> {
|
||||||
|
startTts(pageOverride, startCharIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
activity?.shouldShowRequestPermissionRationale(
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == true -> {
|
||||||
|
showPermissionRationaleDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
Timber.d("Disposing sample MediaPlayer.")
|
Timber.d("Disposing sample MediaPlayer.")
|
||||||
|
|
@ -3971,6 +4003,7 @@ fun PdfViewerScreen(
|
||||||
onHighlightAdd = onHighlightAdd,
|
onHighlightAdd = onHighlightAdd,
|
||||||
onHighlightUpdate = onHighlightUpdate,
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
onHighlightDelete = onHighlightDelete,
|
onHighlightDelete = onHighlightDelete,
|
||||||
|
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
||||||
onTwoFingerSwipe = { direction ->
|
onTwoFingerSwipe = { direction ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val targetPage =
|
val targetPage =
|
||||||
|
|
@ -4302,6 +4335,7 @@ fun PdfViewerScreen(
|
||||||
onHighlightAdd = onHighlightAdd,
|
onHighlightAdd = onHighlightAdd,
|
||||||
onHighlightUpdate = onHighlightUpdate,
|
onHighlightUpdate = onHighlightUpdate,
|
||||||
onHighlightDelete = onHighlightDelete,
|
onHighlightDelete = onHighlightDelete,
|
||||||
|
onTts = { pageIdx, charIdx -> startTtsWithPermissionCheck(pageIdx, charIdx) },
|
||||||
onLinkClicked = onLinkClickedStable,
|
onLinkClicked = onLinkClickedStable,
|
||||||
onInternalLinkClicked = onInternalLinkNavStable,
|
onInternalLinkClicked = onInternalLinkNavStable,
|
||||||
bookmarks = bookmarksHolder,
|
bookmarks = bookmarksHolder,
|
||||||
|
|
@ -5614,27 +5648,7 @@ fun PdfViewerScreen(
|
||||||
Timber.d("TTS button clicked: Stopping TTS")
|
Timber.d("TTS button clicked: Stopping TTS")
|
||||||
ttsController.stop()
|
ttsController.stop()
|
||||||
} else {
|
} else {
|
||||||
executeWithOcrCheck {
|
startTtsWithPermissionCheck(null, null)
|
||||||
when {
|
|
||||||
ContextCompat.checkSelfPermission(
|
|
||||||
context, Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
) == PackageManager.PERMISSION_GRANTED -> {
|
|
||||||
startTts()
|
|
||||||
}
|
|
||||||
|
|
||||||
activity?.shouldShowRequestPermissionRationale(
|
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
) == true -> {
|
|
||||||
showPermissionRationaleDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
permissionLauncher.launch(
|
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue