General improvements (#316)

* Added support for right text alignment in epub reader

* Added tabs management to PDF navigation drawer

* Implemented unified selection menu placement logic

* Added reset functionality for toolbar customization

* Improved highlight filtering in epub pagination

* Migrated hardcoded UI strings to string resources

* Extracted desktop application logic from Main.kt into modular files

* Refactored Main.kt by extracting PDF and EPUB logic into specialized files

* Added Spanish language support

* Added system default option to app language selection
This commit is contained in:
Aryan 2026-05-16 16:19:31 +05:30 committed by GitHub
parent 759d4b73a0
commit 056485a140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 9184 additions and 5947 deletions

View file

@ -21,6 +21,7 @@ enum class ReaderFont(val id: String, val displayName: String, val fontFamilyNam
enum class ReaderTextAlign(val id: String, val cssValue: String, val displayName: String) {
DEFAULT("default", "", "Default"),
LEFT("left", "left", "Left"),
RIGHT("right", "right", "Right"),
JUSTIFY("justify", "justify", "Justify")
}
@ -176,6 +177,7 @@ fun ReaderTextAlign.toSharedReaderTextAlign(): SharedReaderTextAlign {
return when (this) {
ReaderTextAlign.DEFAULT,
ReaderTextAlign.LEFT -> SharedReaderTextAlign.START
ReaderTextAlign.RIGHT -> SharedReaderTextAlign.RIGHT
ReaderTextAlign.JUSTIFY -> SharedReaderTextAlign.JUSTIFY
}
}

View file

@ -212,6 +212,7 @@ object ReaderHtmlDocumentBuilder {
val appearance = settings.toDocumentAppearanceCss(textureDataUri)
val align = when (settings.textAlign) {
SharedReaderTextAlign.START -> "left"
SharedReaderTextAlign.RIGHT -> "right"
SharedReaderTextAlign.JUSTIFY -> "justify"
SharedReaderTextAlign.CENTER -> "center"
}
@ -418,13 +419,14 @@ object ReaderHtmlDocumentBuilder {
display: none;
flex-direction: column;
width: max-content;
max-width: min(300px, calc(100vw - 16px));
max-width: min(280px, calc(100vw - 16px));
padding: 0 0 6px;
border-radius: 14px;
background: color-mix(in srgb, var(--reader-bg) 92%, var(--reader-fg));
border: 1px solid color-mix(in srgb, var(--reader-fg) 18%, transparent);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
overflow: hidden;
max-height: calc(100vh - 16px);
overflow: auto;
}
#reader-selection-menu button {
border: 0;
@ -440,16 +442,16 @@ object ReaderHtmlDocumentBuilder {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
gap: 8px;
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
padding: 8px 10px;
border-bottom: 1px solid color-mix(in srgb, var(--reader-fg) 12%, transparent);
overflow-x: auto;
}
#reader-selection-menu .reader-selection-color {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
flex: 0 0 auto;
padding: 0;
border-radius: 999px;
@ -458,34 +460,34 @@ object ReaderHtmlDocumentBuilder {
}
#reader-selection-menu .reader-selection-actions {
display: grid;
grid-template-columns: repeat(3, 78px);
gap: 4px;
padding: 6px 8px 2px;
grid-template-columns: repeat(3, 70px);
gap: 3px;
padding: 5px 6px 2px;
}
#reader-selection-menu .reader-selection-action {
min-height: 58px;
min-height: 52px;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 4px;
gap: 4px;
padding: 6px 4px;
line-height: 1;
white-space: nowrap;
}
#reader-selection-menu .reader-selection-icon {
display: grid;
place-items: center;
width: 24px;
height: 24px;
width: 22px;
height: 22px;
border-radius: 999px;
background: color-mix(in srgb, var(--reader-fg) 9%, transparent);
color: color-mix(in srgb, var(--reader-fg) 86%, transparent);
}
#reader-selection-menu .reader-selection-icon svg {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
display: block;
fill: currentColor;
}
@ -1185,18 +1187,90 @@ object ReaderHtmlDocumentBuilder {
function positionMenu(left, top, anchorRect) {
menu.style.visibility = 'hidden';
menu.style.display = 'flex';
var margin = 8;
var gap = 14;
var viewportWidth = Math.max(0, window.innerWidth || 0);
var viewportHeight = Math.max(0, window.innerHeight || 0);
menu.style.maxHeight = Math.max(0, viewportHeight - margin * 2) + 'px';
var menuWidth = menu.offsetWidth || 300;
var menuHeight = menu.offsetHeight || 230;
var margin = 8;
var nextLeft = left;
var nextTop = top;
if (anchorRect) {
nextLeft = anchorRect.left + (anchorRect.width / 2) - (menuWidth / 2);
nextTop = anchorRect.top - menuHeight - 14;
if (nextTop < margin) nextTop = anchorRect.bottom + 14;
function clampMenuStart(preferred, size, viewportSize) {
if (viewportSize <= 0 || size <= 0) return 0;
if (viewportSize <= size) return 0;
var maxStart = viewportSize - size;
var minStart = Math.min(margin, maxStart);
var maxClampedStart = Math.max(minStart, viewportSize - size - margin);
return Math.max(minStart, Math.min(maxClampedStart, preferred));
}
function selectionMenuCandidate(x, y) {
return {
left: clampMenuStart(x, menuWidth, viewportWidth),
top: clampMenuStart(y, menuHeight, viewportHeight)
};
}
function overlapAreaWithSelection(candidate, rect) {
var overlapWidth = Math.min(candidate.left + menuWidth, rect.right) - Math.max(candidate.left, rect.left);
var overlapHeight = Math.min(candidate.top + menuHeight, rect.bottom) - Math.max(candidate.top, rect.top);
return Math.max(0, overlapWidth) * Math.max(0, overlapHeight);
}
function distanceFromSelection(candidate, rect) {
var dx = Math.max(rect.left - (candidate.left + menuWidth), candidate.left - rect.right, 0);
var dy = Math.max(rect.top - (candidate.top + menuHeight), candidate.top - rect.bottom, 0);
return dx * dx + dy * dy;
}
var rect = anchorRect ? {
left: Math.max(0, Math.min(viewportWidth, Math.min(anchorRect.left, anchorRect.right))),
top: Math.max(0, Math.min(viewportHeight, Math.min(anchorRect.top, anchorRect.bottom))),
right: Math.max(0, Math.min(viewportWidth, Math.max(anchorRect.left, anchorRect.right))),
bottom: Math.max(0, Math.min(viewportHeight, Math.max(anchorRect.top, anchorRect.bottom)))
} : {
left: Math.max(0, Math.min(viewportWidth, left)),
top: Math.max(0, Math.min(viewportHeight, top)),
right: Math.max(0, Math.min(viewportWidth, left)),
bottom: Math.max(0, Math.min(viewportHeight, top))
};
var centerX = (rect.left + rect.right) / 2;
var centerY = (rect.top + rect.bottom) / 2;
var above = selectionMenuCandidate(centerX - menuWidth / 2, rect.top - gap - menuHeight);
var below = selectionMenuCandidate(centerX - menuWidth / 2, rect.bottom + gap);
var right = selectionMenuCandidate(rect.right + gap, centerY - menuHeight / 2);
var leftSide = selectionMenuCandidate(rect.left - gap - menuWidth, centerY - menuHeight / 2);
var nextLeft = above.left;
var nextTop = above.top;
if (above.top + menuHeight <= rect.top - gap && above.top >= margin) {
nextLeft = above.left;
nextTop = above.top;
} else if (below.top >= rect.bottom + gap && below.top + menuHeight <= viewportHeight - margin) {
nextLeft = below.left;
nextTop = below.top;
} else {
var leftSpace = rect.left - gap - margin;
var rightSpace = viewportWidth - rect.right - gap - margin;
var firstSide = rightSpace >= leftSpace ? right : leftSide;
var secondSide = rightSpace >= leftSpace ? leftSide : right;
var firstFits = firstSide === right
? firstSide.left >= rect.right + gap
: firstSide.left + menuWidth <= rect.left - gap;
var secondFits = secondSide === right
? secondSide.left >= rect.right + gap
: secondSide.left + menuWidth <= rect.left - gap;
if (firstFits && firstSide.top >= margin && firstSide.top + menuHeight <= viewportHeight - margin) {
nextLeft = firstSide.left;
nextTop = firstSide.top;
} else if (secondFits && secondSide.top >= margin && secondSide.top + menuHeight <= viewportHeight - margin) {
nextLeft = secondSide.left;
nextTop = secondSide.top;
} else {
var fallback = [above, below, firstSide, secondSide].sort(function (a, b) {
var overlapDelta = overlapAreaWithSelection(a, rect) - overlapAreaWithSelection(b, rect);
if (overlapDelta !== 0) return overlapDelta;
return distanceFromSelection(a, rect) - distanceFromSelection(b, rect);
})[0];
nextLeft = fallback.left;
nextTop = fallback.top;
}
}
nextLeft = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, nextLeft));
nextTop = Math.max(margin, Math.min(window.innerHeight - menuHeight - margin, nextTop));
menu.style.left = nextLeft + 'px';
menu.style.top = nextTop + 'px';
menu.style.visibility = 'visible';
@ -2458,7 +2532,11 @@ object ReaderHtmlDocumentBuilder {
}
});
document.addEventListener('pointercancel', function () {
if (!activeSelectionHandle) selectionPointerDown = false;
if (!activeSelectionHandle) {
selectionPointerDown = false;
scheduleMenuFromSelection();
scheduleVisiblePageReport();
}
});
document.addEventListener('mouseup', function (event) {
if (menu.contains(event.target)) return;
@ -2467,6 +2545,19 @@ object ReaderHtmlDocumentBuilder {
scheduleMenuFromSelection();
scheduleVisiblePageReport();
});
document.addEventListener('touchend', function (event) {
if (menu.contains(event.target)) return;
if (activeSelectionHandle) return;
selectionPointerDown = false;
scheduleMenuFromSelection();
scheduleVisiblePageReport();
}, { passive: true });
document.addEventListener('touchcancel', function () {
if (activeSelectionHandle) return;
selectionPointerDown = false;
scheduleMenuFromSelection();
scheduleVisiblePageReport();
}, { passive: true });
document.addEventListener('keyup', function () {
scheduleMenuFromSelection();
});

View file

@ -45,6 +45,7 @@ enum class ReaderPageSpreadMode {
enum class SharedReaderTextAlign {
START,
RIGHT,
JUSTIFY,
CENTER
}

View file

@ -350,9 +350,10 @@ fun SharedNativePaginatedReader(
)
}
if (!selectionGestureActive && !selectionHandleDragging) {
val highlightPalette = renderPlan.highlightPalette.sanitized().colors
SharedNativeSelectionMenu(
selection = selection,
highlightPalette = renderPlan.highlightPalette.sanitized().colors,
highlightPalette = highlightPalette,
enabledSelectionActions = enabledSelectionActions,
background = renderPlan.background,
foreground = renderPlan.foreground,
@ -375,7 +376,9 @@ fun SharedNativePaginatedReader(
sharedNativeSelectionMenuOffset(
selection = selection,
readerCoordinates = readerCoordinates,
density = density
density = density,
highlightPaletteSize = highlightPalette.size,
actionCount = enabledSelectionActions.size + 2
)
}
)
@ -620,21 +623,21 @@ private fun SharedNativeSelectionMenu(
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.widthIn(max = 300.dp)
.widthIn(max = 280.dp)
.padding(bottom = 6.dp)
) {
if (highlightPalette.isNotEmpty()) {
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
highlightPalette.forEach { color ->
Box(
modifier = Modifier
.size(28.dp)
.size(24.dp)
.clip(CircleShape)
.background(color.color)
.border(
@ -650,8 +653,8 @@ private fun SharedNativeSelectionMenu(
}
Column(
modifier = Modifier
.padding(start = 8.dp, top = 6.dp, end = 8.dp, bottom = 2.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
.padding(start = 6.dp, top = 5.dp, end = 6.dp, bottom = 2.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
actions.chunked(3).forEach { rowActions ->
Row(
@ -688,17 +691,17 @@ private fun SharedNativeSelectionIconButton(
) {
Column(
modifier = Modifier
.width(78.dp)
.height(58.dp)
.width(70.dp)
.height(52.dp)
.clip(RoundedCornerShape(10.dp))
.clickable { action.onClick() }
.padding(horizontal = 4.dp, vertical = 7.dp),
.padding(horizontal = 4.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically)
) {
Box(
modifier = Modifier
.size(24.dp)
.size(22.dp)
.clip(CircleShape)
.background(iconBackground),
contentAlignment = Alignment.Center
@ -707,7 +710,7 @@ private fun SharedNativeSelectionIconButton(
imageVector = action.icon,
contentDescription = action.label,
tint = iconColor,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(16.dp)
)
}
Text(
@ -1878,27 +1881,53 @@ private data class SharedNativeSelectionEndpoint(
private fun sharedNativeSelectionMenuOffset(
selection: SharedNativeReaderTextSelection,
readerCoordinates: LayoutCoordinates?,
density: Density
density: Density,
highlightPaletteSize: Int,
actionCount: Int
): IntOffset {
val coordinates = readerCoordinates?.takeIf { it.isAttached } ?: return IntOffset(16, 16)
if (selection.rect == Rect.Zero) return IntOffset(16, 16)
val centerX = (selection.rect.left + selection.rect.right) / 2f
val topLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.top))
val bottomLocal = coordinates.windowToLocal(Offset(centerX, selection.rect.bottom))
val leftTopLocal = coordinates.windowToLocal(Offset(selection.rect.left, selection.rect.top))
val rightBottomLocal = coordinates.windowToLocal(Offset(selection.rect.right, selection.rect.bottom))
val paddingPx = with(density) { 16.dp.toPx() }
val estimatedWidthPx = with(density) { 300.dp.toPx() }
val estimatedHeightPx = with(density) { 154.dp.toPx() }
val maxX = (coordinates.size.width - estimatedWidthPx - paddingPx).coerceAtLeast(paddingPx)
val x = (topLocal.x - estimatedWidthPx / 2f).coerceIn(paddingPx, maxX)
val yAbove = topLocal.y - estimatedHeightPx - paddingPx
val y = if (yAbove >= paddingPx) {
yAbove
} else {
(bottomLocal.y + paddingPx).coerceAtMost(
(coordinates.size.height - estimatedHeightPx - paddingPx).coerceAtLeast(paddingPx)
)
val estimatedWidthPx = with(density) { 280.dp.toPx() }
val estimatedHeightPx = sharedNativeSelectionMenuEstimatedHeightPx(
density = density,
highlightPaletteSize = highlightPaletteSize,
actionCount = actionCount
)
val selectionRect = SharedSelectionMenuRect(
left = leftTopLocal.x,
top = leftTopLocal.y,
right = rightBottomLocal.x,
bottom = rightBottomLocal.y
)
val placement = sharedSelectionMenuPlacement(
viewport = SharedSelectionMenuViewport(coordinates.size.width, coordinates.size.height),
popup = SharedSelectionMenuSize(
width = estimatedWidthPx.roundToInt(),
height = estimatedHeightPx.roundToInt()
),
selection = selectionRect,
marginPx = paddingPx,
gapPx = paddingPx
)
return IntOffset(placement.x, placement.y)
}
private fun sharedNativeSelectionMenuEstimatedHeightPx(
density: Density,
highlightPaletteSize: Int,
actionCount: Int
): Float {
val actionRows = ((actionCount.coerceAtLeast(1) + 2) / 3).coerceAtLeast(1)
return with(density) {
val paletteHeight = if (highlightPaletteSize > 0) 41.dp.toPx() else 0f
val actionsHeight = 7.dp.toPx() +
(actionRows * 52).dp.toPx() +
((actionRows - 1).coerceAtLeast(0) * 3).dp.toPx()
paletteHeight + actionsHeight
}
return IntOffset(x.roundToInt(), y.roundToInt())
}
private fun sharedNativeSelectionHandleOffset(
@ -2395,6 +2424,7 @@ private fun ReaderSettings.renderedDefaultBlockSpacingDp(): Dp {
private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
return when (this) {
SharedReaderTextAlign.START -> TextAlign.Start
SharedReaderTextAlign.RIGHT -> TextAlign.Right
SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
SharedReaderTextAlign.CENTER -> TextAlign.Center
}

View file

@ -1495,6 +1495,13 @@ fun SharedReaderFormatControls(
},
label = { Text("Left") }
)
FilterChip(
selected = settings.textAlign == SharedReaderTextAlign.RIGHT,
onClick = {
onReaderAction(ReaderAction.SettingsChanged(settings.copy(textAlign = SharedReaderTextAlign.RIGHT)))
},
label = { Text("Right") }
)
FilterChip(
selected = settings.textAlign == SharedReaderTextAlign.JUSTIFY,
onClick = {

View file

@ -0,0 +1,182 @@
package com.aryan.reader.shared.ui
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
data class SharedSelectionMenuViewport(
val width: Int,
val height: Int
)
data class SharedSelectionMenuSize(
val width: Int,
val height: Int
)
data class SharedSelectionMenuRect(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float
)
enum class SharedSelectionMenuPlacement {
ABOVE,
BELOW,
LEFT,
RIGHT,
FALLBACK
}
data class SharedSelectionMenuPlacementResult(
val x: Int,
val y: Int,
val placement: SharedSelectionMenuPlacement
)
fun sharedSelectionMenuPlacement(
viewport: SharedSelectionMenuViewport,
popup: SharedSelectionMenuSize,
selection: SharedSelectionMenuRect,
marginPx: Float,
gapPx: Float
): SharedSelectionMenuPlacementResult {
val viewportWidth = viewport.width.coerceAtLeast(0).toFloat()
val viewportHeight = viewport.height.coerceAtLeast(0).toFloat()
val popupWidth = popup.width.coerceAtLeast(0).toFloat()
val popupHeight = popup.height.coerceAtLeast(0).toFloat()
val margin = marginPx.coerceAtLeast(0f)
val gap = gapPx.coerceAtLeast(0f)
val keepClear = selection.normalized().clampedToViewport(viewportWidth, viewportHeight)
fun centeredX(): Float = keepClear.centerX - popupWidth / 2f
fun centeredY(): Float = keepClear.centerY - popupHeight / 2f
fun clamped(x: Float, y: Float): SharedSelectionMenuCandidate {
return SharedSelectionMenuCandidate(
x = clampStart(x, popupWidth, viewportWidth, margin),
y = clampStart(y, popupHeight, viewportHeight, margin)
)
}
val above = clamped(centeredX(), keepClear.top - gap - popupHeight)
if (above.y + popupHeight <= keepClear.top - gap && above.y >= margin) {
return above.toResult(SharedSelectionMenuPlacement.ABOVE)
}
val below = clamped(centeredX(), keepClear.bottom + gap)
if (below.y >= keepClear.bottom + gap && below.y + popupHeight <= viewportHeight - margin) {
return below.toResult(SharedSelectionMenuPlacement.BELOW)
}
val leftSpace = keepClear.left - gap - margin
val rightSpace = viewportWidth - keepClear.right - gap - margin
val sideCandidates = if (rightSpace >= leftSpace) {
listOf(
SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY()),
SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY())
)
} else {
listOf(
SharedSelectionMenuPlacement.LEFT to clamped(keepClear.left - gap - popupWidth, centeredY()),
SharedSelectionMenuPlacement.RIGHT to clamped(keepClear.right + gap, centeredY())
)
}
sideCandidates.forEach { (placement, candidate) ->
val fitsHorizontally = when (placement) {
SharedSelectionMenuPlacement.LEFT -> candidate.x + popupWidth <= keepClear.left - gap
SharedSelectionMenuPlacement.RIGHT -> candidate.x >= keepClear.right + gap
else -> false
}
if (fitsHorizontally && candidate.y >= margin && candidate.y + popupHeight <= viewportHeight - margin) {
return candidate.toResult(placement)
}
}
return listOf(
above,
below,
sideCandidates[0].second,
sideCandidates[1].second
).minWith(
compareBy<SharedSelectionMenuCandidate> { candidate ->
candidate.overlapAreaWith(keepClear, popupWidth, popupHeight)
}.thenBy { candidate ->
candidate.distanceFrom(keepClear, popupWidth, popupHeight)
}
).toResult(SharedSelectionMenuPlacement.FALLBACK)
}
private data class SharedSelectionMenuCandidate(
val x: Float,
val y: Float
) {
fun toResult(placement: SharedSelectionMenuPlacement): SharedSelectionMenuPlacementResult {
return SharedSelectionMenuPlacementResult(
x = x.roundToInt(),
y = y.roundToInt(),
placement = placement
)
}
fun overlapAreaWith(
rect: SharedSelectionMenuRect,
width: Float,
height: Float
): Float {
val overlapWidth = min(x + width, rect.right) - max(x, rect.left)
val overlapHeight = min(y + height, rect.bottom) - max(y, rect.top)
return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f)
}
fun distanceFrom(
rect: SharedSelectionMenuRect,
width: Float,
height: Float
): Float {
val dx = maxOf(rect.left - (x + width), x - rect.right, 0f)
val dy = maxOf(rect.top - (y + height), y - rect.bottom, 0f)
return dx * dx + dy * dy
}
}
private fun SharedSelectionMenuRect.normalized(): SharedSelectionMenuRect {
return SharedSelectionMenuRect(
left = min(left, right),
top = min(top, bottom),
right = max(left, right),
bottom = max(top, bottom)
)
}
private val SharedSelectionMenuRect.centerX: Float
get() = (left + right) / 2f
private val SharedSelectionMenuRect.centerY: Float
get() = (top + bottom) / 2f
private fun SharedSelectionMenuRect.clampedToViewport(
viewportWidth: Float,
viewportHeight: Float
): SharedSelectionMenuRect {
return SharedSelectionMenuRect(
left = left.coerceIn(0f, viewportWidth),
top = top.coerceIn(0f, viewportHeight),
right = right.coerceIn(0f, viewportWidth),
bottom = bottom.coerceIn(0f, viewportHeight)
).normalized()
}
private fun clampStart(
preferred: Float,
popupSize: Float,
viewportSize: Float,
margin: Float
): Float {
if (viewportSize <= 0f || popupSize <= 0f) return 0f
if (viewportSize <= popupSize) return 0f
val maxStart = viewportSize - popupSize
val min = margin.coerceIn(0f, maxStart)
val max = (viewportSize - popupSize - margin).coerceAtLeast(min)
return preferred.coerceIn(min, max)
}

View file

@ -287,7 +287,7 @@ class ReaderActionReducerTest {
verticalMargin = 2.0f,
font = ReaderFont.ROBOTO_MONO,
customPath = null,
textAlign = ReaderTextAlign.JUSTIFY
textAlign = ReaderTextAlign.RIGHT
)
),
engine
@ -301,7 +301,7 @@ class ReaderActionReducerTest {
assertEquals(0.8f, updated.reader.settings.paragraphSpacing, 0.0001f)
assertEquals(1.3f, updated.reader.settings.imageScale, 0.0001f)
assertEquals("Mono", updated.reader.settings.fontFamily)
assertEquals(SharedReaderTextAlign.JUSTIFY, updated.reader.settings.textAlign)
assertEquals(SharedReaderTextAlign.RIGHT, updated.reader.settings.textAlign)
assertTrue(updated.reader.settings.darkMode)
assertEquals(ReaderReadingMode.VERTICAL, updated.reader.settings.readingMode)
assertEquals(812, updated.reader.settings.pageWidth)

View file

@ -27,6 +27,17 @@ import kotlin.test.assertTrue
class ReaderHtmlDocumentBuilderTest {
@Test
fun `page document writes right alignment reader css variable`() {
val html = ReaderHtmlDocumentBuilder.pageDocument(
book = repeatedWordBook("alpha beta"),
page = ReaderPage(0, 0, "One", "alpha beta", 0, 10),
settings = ReaderSettings(textAlign = SharedReaderTextAlign.RIGHT)
)
assertTrue(html.contains("--reader-align: right;"))
}
@Test
fun `page document renders only the highlighted occurrence from locator offsets`() {
val text = "alpha beta alpha beta"
@ -321,11 +332,14 @@ class ReaderHtmlDocumentBuilderTest {
assertTrue(html.contains("function scheduleMenuFromSelection()"))
assertTrue(html.contains("selectionAnchorRect(selection)"))
assertTrue(html.contains("selectionMenuCandidate"))
assertTrue(html.contains("overlapAreaWithSelection"))
assertTrue(html.contains("if (selectionPointerDown || activeSelectionHandle) return;"))
assertTrue(html.contains("rangeBoundaryRect(range.startContainer"))
assertTrue(html.contains("document.addEventListener('selectionchange'"))
assertTrue(html.contains("document.addEventListener('pointerdown'"))
assertTrue(html.contains("document.addEventListener('mouseup'"))
assertTrue(html.contains("document.addEventListener('touchend'"))
assertTrue(html.contains("document.addEventListener('contextmenu'"))
}

View file

@ -0,0 +1,99 @@
package com.aryan.reader.shared.ui
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedSelectionMenuPlacementTest {
@Test
fun `places menu above selection when there is room`() {
val result = sharedSelectionMenuPlacement(
viewport = SharedSelectionMenuViewport(width = 800, height = 600),
popup = SharedSelectionMenuSize(width = 240, height = 120),
selection = SharedSelectionMenuRect(left = 300f, top = 300f, right = 360f, bottom = 330f),
marginPx = 16f,
gapPx = 12f
)
assertEquals(SharedSelectionMenuPlacement.ABOVE, result.placement)
assertEquals(210, result.x)
assertEquals(168, result.y)
}
@Test
fun `places menu below selection when above is blocked`() {
val result = sharedSelectionMenuPlacement(
viewport = SharedSelectionMenuViewport(width = 800, height = 600),
popup = SharedSelectionMenuSize(width = 240, height = 120),
selection = SharedSelectionMenuRect(left = 300f, top = 40f, right = 360f, bottom = 70f),
marginPx = 16f,
gapPx = 12f
)
assertEquals(SharedSelectionMenuPlacement.BELOW, result.placement)
assertEquals(210, result.x)
assertEquals(82, result.y)
}
@Test
fun `places menu on wider side in short landscape viewport`() {
val result = sharedSelectionMenuPlacement(
viewport = SharedSelectionMenuViewport(width = 800, height = 320),
popup = SharedSelectionMenuSize(width = 240, height = 180),
selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f),
marginPx = 16f,
gapPx = 12f
)
assertEquals(SharedSelectionMenuPlacement.RIGHT, result.placement)
assertEquals(392, result.x)
assertEquals(65, result.y)
}
@Test
fun `keeps menu off selected text when a valid side placement exists`() {
val result = sharedSelectionMenuPlacement(
viewport = SharedSelectionMenuViewport(width = 800, height = 320),
popup = SharedSelectionMenuSize(width = 240, height = 180),
selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f),
marginPx = 16f,
gapPx = 12f
)
assertEquals(0f, result.rect(width = 240, height = 180).overlapAreaWith(
SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f)
))
}
@Test
fun `falls back predictably when selection consumes the viewport`() {
val result = sharedSelectionMenuPlacement(
viewport = SharedSelectionMenuViewport(width = 320, height = 220),
popup = SharedSelectionMenuSize(width = 260, height = 180),
selection = SharedSelectionMenuRect(left = 10f, top = 20f, right = 310f, bottom = 200f),
marginPx = 16f,
gapPx = 12f
)
assertEquals(SharedSelectionMenuPlacement.FALLBACK, result.placement)
assertEquals(30, result.x)
assertEquals(16, result.y)
}
}
private fun SharedSelectionMenuPlacementResult.rect(
width: Int,
height: Int
): SharedSelectionMenuRect {
return SharedSelectionMenuRect(
left = x.toFloat(),
top = y.toFloat(),
right = x + width.toFloat(),
bottom = y + height.toFloat()
)
}
private fun SharedSelectionMenuRect.overlapAreaWith(other: SharedSelectionMenuRect): Float {
val overlapWidth = minOf(right, other.right) - maxOf(left, other.left)
val overlapHeight = minOf(bottom, other.bottom) - maxOf(top, other.top)
return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f)
}

View file

@ -719,6 +719,7 @@ class SharedMeasuredEpubPaginator(
private fun SharedReaderTextAlign.toComposeTextAlign(): TextAlign {
return when (this) {
SharedReaderTextAlign.START -> TextAlign.Start
SharedReaderTextAlign.RIGHT -> TextAlign.Right
SharedReaderTextAlign.JUSTIFY -> TextAlign.Justify
SharedReaderTextAlign.CENTER -> TextAlign.Center
}