Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .run/SeforimApp [run].run.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="SEFORIMAPP_DATABASE_PATH" value="$PROJECT_DIR$/SeforimLibrary/build/seforim.db" />
<entry key="SEFORIMAPP_LOGGING" value="true" />
<entry key="SEFORIMAPP_REPOSITORY_LOGGIN" value="true" />
</map>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
<string name="context_menu_search_selected_text">חפש במאגר</string>
<string name="context_menu_find_in_page">חפש בעמוד</string>
<string name="context_menu_copy_without_nikud">העתק ללא ניקוד</string>
<string name="context_menu_highlight">סמן</string>

<!-- Book Content Screen - End Vertical Bar -->
<string name="tools">כלים</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package io.github.kdroidfilter.seforimapp.core

import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextRange
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

/**
* Represents a single user highlight with position information.
* Highlights are stored per line with character offsets for precise positioning.
*
* @property lineId The ID of the line containing this highlight
* @property startOffset The starting character offset within the line's text
* @property endOffset The ending character offset within the line's text
* @property color The highlight color
*/
@Stable
data class UserHighlight(
val lineId: Long,
val startOffset: Int,
val endOffset: Int,
val color: Color,
) {
/** Returns the text range of this highlight */
val textRange: TextRange get() = TextRange(startOffset, endOffset)
}

/**
* Available highlight colors for the user to choose from.
*/
object HighlightColors {
val Yellow = Color(0xFFFFEB3B)
val Green = Color(0xFF4CAF50)
val Blue = Color(0xFF2196F3)
val Pink = Color(0xFFE91E63)
val Orange = Color(0xFFFF9800)
val Transparent = Color.Transparent

val all = listOf(Yellow, Green, Blue, Pink, Orange)
val allWithClear = listOf(Yellow, Green, Blue, Pink, Orange, Transparent)
}

/**
* In-memory store for user highlights.
* Highlights are stored per book with position information (lineId + offsets).
* Will be lost when the application closes.
*/
object HighlightStore {
// Map of bookId to list of highlights for that book
private val _highlightsByBook = MutableStateFlow<Map<Long, List<UserHighlight>>>(emptyMap())
val highlightsByBook: StateFlow<Map<Long, List<UserHighlight>>> = _highlightsByBook.asStateFlow()

/**
* Adds a new highlight for the specified book and line.
* If an overlapping highlight exists on the same line, it merges or updates.
*/
fun addHighlight(
bookId: Long,
lineId: Long,
startOffset: Int,
endOffset: Int,
color: Color,
) {
if (startOffset >= endOffset) return

_highlightsByBook.update { current ->
val bookHighlights = current[bookId].orEmpty().toMutableList()

// Find existing highlight on the same line that overlaps
val existingIndex =
bookHighlights.indexOfFirst { hl ->
hl.lineId == lineId && rangesOverlap(hl.startOffset, hl.endOffset, startOffset, endOffset)
}

if (existingIndex >= 0) {
// Update existing highlight (replace with new range/color)
bookHighlights[existingIndex] = UserHighlight(lineId, startOffset, endOffset, color)
} else {
// Add new highlight
bookHighlights.add(UserHighlight(lineId, startOffset, endOffset, color))
}

current + (bookId to bookHighlights)
}
}

/**
* Removes highlights that overlap with the specified range on the given line.
*/
fun removeHighlight(
bookId: Long,
lineId: Long,
startOffset: Int,
endOffset: Int,
) {
_highlightsByBook.update { current ->
val bookHighlights =
current[bookId].orEmpty().filter { hl ->
!(hl.lineId == lineId && rangesOverlap(hl.startOffset, hl.endOffset, startOffset, endOffset))
}
if (bookHighlights.isEmpty()) {
current - bookId
} else {
current + (bookId to bookHighlights)
}
}
}

/**
* Gets all highlights for a specific book.
*/
fun getHighlightsForBook(bookId: Long): List<UserHighlight> = _highlightsByBook.value[bookId].orEmpty()

/**
* Gets all highlights for a specific line in a book.
*/
fun getHighlightsForLine(
bookId: Long,
lineId: Long,
): List<UserHighlight> = _highlightsByBook.value[bookId].orEmpty().filter { it.lineId == lineId }

/**
* Clears all highlights for a specific book.
*/
fun clearHighlightsForBook(bookId: Long) {
_highlightsByBook.update { current ->
current - bookId
}
}

/**
* Clears all highlights.
*/
fun clearAll() {
_highlightsByBook.value = emptyMap()
}

private fun rangesOverlap(
start1: Int,
end1: Int,
start2: Int,
end2: Int,
): Boolean = start1 < end2 && start2 < end1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package io.github.kdroidfilter.seforimapp.core

import androidx.compose.runtime.Stable
import io.github.kdroidfilter.seforimapp.core.presentation.text.findAllMatchesOriginal

/**
* Represents a match found in a line.
*
* @property lineId The ID of the line containing the match
* @property startOffset The starting character offset within the line's plain text
* @property endOffset The ending character offset within the line's plain text
*/
@Stable
data class LineMatch(
val lineId: Long,
val startOffset: Int,
val endOffset: Int,
)

/**
* Registry that tracks the plain text content of lines for a book.
* Used to find which line(s) contain selected text.
*
* Thread-safe for concurrent access.
*/
class LineTextRegistry {
// Map of lineId to plain text content
private val lineTexts = mutableMapOf<Long, String>()

/**
* Registers or updates the plain text for a line.
*/
@Synchronized
fun registerLine(
lineId: Long,
plainText: String,
) {
lineTexts[lineId] = plainText
}

/**
* Unregisters a line (e.g., when it's no longer visible).
*/
@Synchronized
fun unregisterLine(lineId: Long) {
lineTexts.remove(lineId)
}

/**
* Clears all registered lines.
*/
@Synchronized
fun clear() {
lineTexts.clear()
}

/**
* Finds all lines that contain the given text.
* Uses diacritic-insensitive matching for Hebrew text.
*
* @param selectedText The text to search for
* @return List of matches with line IDs and offsets
*/
@Synchronized
fun findMatches(selectedText: String): List<LineMatch> {
val trimmed = selectedText.trim()
if (trimmed.isEmpty()) return emptyList()

val matches = mutableListOf<LineMatch>()
for ((lineId, text) in lineTexts) {
val ranges = findAllMatchesOriginal(text, trimmed)
for (range in ranges) {
matches.add(
LineMatch(
lineId = lineId,
startOffset = range.first,
endOffset = range.last + 1,
),
)
}
}
return matches
}

/**
* Finds a match in a specific line, closest to the given character offset.
* Use this when you know which line and approximate position the user is interacting with.
*
* @param selectedText The text to search for
* @param lineId The specific line to search in
* @param nearOffset Optional character offset to find the closest match to
* @return The match in that line closest to the offset, or null if not found
*/
@Synchronized
fun findMatchInLine(
selectedText: String,
lineId: Long,
nearOffset: Int? = null,
): LineMatch? {
val trimmed = selectedText.trim()
if (trimmed.isEmpty()) return null

val text = lineTexts[lineId] ?: return null
val ranges = findAllMatchesOriginal(text, trimmed)
if (ranges.isEmpty()) return null

// If no offset hint, return first match
if (nearOffset == null) {
val range = ranges.first()
return LineMatch(
lineId = lineId,
startOffset = range.first,
endOffset = range.last + 1,
)
}

// Find the match closest to the given offset
val closestRange =
ranges.minByOrNull { range ->
minOf(
kotlin.math.abs(range.first - nearOffset),
kotlin.math.abs(range.last - nearOffset),
)
} ?: ranges.first()

return LineMatch(
lineId = lineId,
startOffset = closestRange.first,
endOffset = closestRange.last + 1,
)
}

/**
* Finds the first line that contains the given text.
* Searches lines in sorted order by lineId for deterministic results.
* Falls back to this when no specific line is known.
*
* @param selectedText The text to search for
* @return The first match (by lineId order), or null if not found
*/
@Synchronized
fun findFirstMatch(selectedText: String): LineMatch? {
val trimmed = selectedText.trim()
if (trimmed.isEmpty()) return null

// Sort by lineId for deterministic iteration order
for ((lineId, text) in lineTexts.entries.sortedBy { it.key }) {
val ranges = findAllMatchesOriginal(text, trimmed)
if (ranges.isNotEmpty()) {
val range = ranges.first()
return LineMatch(
lineId = lineId,
startOffset = range.first,
endOffset = range.last + 1,
)
}
}
return null
}

/**
* Gets the plain text for a specific line.
*/
@Synchronized
fun getLineText(lineId: Long): String? = lineTexts[lineId]

/**
* Gets the number of registered lines.
*/
@Synchronized
fun size(): Int = lineTexts.size
}

/**
* Global registry instance for the current book view.
* In a multi-tab scenario, each tab should have its own registry.
*/
object GlobalLineTextRegistry {
private val registries = mutableMapOf<String, LineTextRegistry>()

/**
* Gets or creates a registry for the given tab.
*/
@Synchronized
fun getForTab(tabId: String): LineTextRegistry = registries.getOrPut(tabId) { LineTextRegistry() }

/**
* Removes the registry for the given tab.
*/
@Synchronized
fun removeForTab(tabId: String) {
registries.remove(tabId)
}

/**
* Clears all registries.
*/
@Synchronized
fun clearAll() {
registries.clear()
}
}
Loading
Loading