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:
parent
759d4b73a0
commit
056485a140
77 changed files with 9184 additions and 5947 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ enum class ReaderPageSpreadMode {
|
|||
|
||||
enum class SharedReaderTextAlign {
|
||||
START,
|
||||
RIGHT,
|
||||
JUSTIFY,
|
||||
CENTER
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue