Jelajahi Sumber

feat: 笔记样式优化

HapeLee 1 bulan lalu
induk
melakukan
e3cc970e49

+ 3 - 5
app/src/main/java/io/legado/app/domain/model/BookContentProcessModels.kt

@@ -65,17 +65,15 @@ enum class MarkingEffect {
 
 
     /**
     /**
      * 由效果 + 选中颜色生成样式。背景色自动半透明(约 20% alpha),
      * 由效果 + 选中颜色生成样式。背景色自动半透明(约 20% alpha),
-     * 避免不透明背景盖住正文;下划线/字体色用原色。
+     * 避免不透明背景盖住正文;下划线/荧光/字体色用原色,透明度由所选颜色的
+     * alpha 决定,不再附加默认透明度。
      */
      */
     fun toStyle(color: Int): TextProcessStyle = when (this) {
     fun toStyle(color: Int): TextProcessStyle = when (this) {
         SOLID -> TextProcessStyle(underlineMode = 1, underlineColor = color)
         SOLID -> TextProcessStyle(underlineMode = 1, underlineColor = color)
         WAVE -> TextProcessStyle(underlineMode = 3, underlineColor = color)
         WAVE -> TextProcessStyle(underlineMode = 3, underlineColor = color)
         DASHED -> TextProcessStyle(underlineMode = 2, underlineColor = color)
         DASHED -> TextProcessStyle(underlineMode = 2, underlineColor = color)
         STRIKE -> TextProcessStyle(underlineMode = 6, underlineColor = color)
         STRIKE -> TextProcessStyle(underlineMode = 6, underlineColor = color)
-        HIGHLIGHT -> TextProcessStyle(
-            underlineMode = 7,
-            underlineColor = (color and 0x00FFFFFF) or 0x66000000,
-        )
+        HIGHLIGHT -> TextProcessStyle(underlineMode = 7, underlineColor = color)
         BG -> TextProcessStyle(bgColor = (color and 0x00FFFFFF) or 0x33000000)
         BG -> TextProcessStyle(bgColor = (color and 0x00FFFFFF) or 0x33000000)
         TEXT -> TextProcessStyle(textColor = color)
         TEXT -> TextProcessStyle(textColor = color)
     }
     }

+ 37 - 11
app/src/main/java/io/legado/app/ui/book/read/MarkingSelectionMenu.kt

@@ -1,9 +1,11 @@
 package io.legado.app.ui.book.read
 package io.legado.app.ui.book.read
 
 
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.ExperimentalFoundationApi
 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
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
@@ -57,6 +59,7 @@ import io.legado.app.R
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.domain.model.MarkingEffect
 import io.legado.app.domain.model.MarkingEffect
 import io.legado.app.domain.model.TextProcessStyle
 import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.ui.book.read.sheet.MarkingPresetColors
 import io.legado.app.ui.book.read.sheet.labelRes
 import io.legado.app.ui.book.read.sheet.labelRes
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ProvideAppDensity
 import io.legado.app.ui.theme.ProvideAppDensity
@@ -82,7 +85,11 @@ fun MarkingSelectionMenu(
     val windowSize = LocalWindowInfo.current.containerSize
     val windowSize = LocalWindowInfo.current.containerSize
     val width = with(density) { windowSize.width.toDp() } - 32.dp
     val width = with(density) { windowSize.width.toDp() } - 32.dp
     val maxHeight = with(density) { windowSize.height.toDp() } - 32.dp
     val maxHeight = with(density) { windowSize.height.toDp() } - 32.dp
-    val opensBelow = (menuState.startTopY + menuState.endBottomY) / 2f < windowSize.height / 2f
+    // 与 TextMenuPositionProvider 同一套落位规则:默认在选区下方展开,只有选区贴近窗口底部、
+    // 下方放不下卡片时才上翻。卡片真实高度要等测量,而缩放原点必须在入场动画前确定,这里用
+    // 标记卡的常规高度估算;它只影响缩放动画的原点,落位始终由 positionProvider 决定。
+    val opensBelow = windowSize.height - menuState.endBottomY >
+            with(density) { MARKING_MENU_ESTIMATED_HEIGHT.toPx() }
     val transformOrigin = TransformOrigin(0.5f, if (opensBelow) 0f else 1f)
     val transformOrigin = TransformOrigin(0.5f, if (opensBelow) 0f else 1f)
     val shadowPadding = 12.dp
     val shadowPadding = 12.dp
     val positionProvider = remember(menuState, density.density) {
     val positionProvider = remember(menuState, density.density) {
@@ -94,7 +101,6 @@ fun MarkingSelectionMenu(
             endX = menuState.endX,
             endX = menuState.endX,
             endBottomY = menuState.endBottomY,
             endBottomY = menuState.endBottomY,
             shadowPadding = with(density) { shadowPadding.roundToPx() },
             shadowPadding = with(density) { shadowPadding.roundToPx() },
-            placeOppositeHalf = true,
         )
         )
     }
     }
 
 
@@ -126,6 +132,10 @@ fun MarkingSelectionMenu(
     var noteInitialized by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
     var noteInitialized by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
     var noteDirty by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
     var noteDirty by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
     var showColorPicker by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
     var showColorPicker by remember(state.selection, state.editing?.id) { mutableStateOf(false) }
+    // 打开取色器的起始色:点尾部按钮用当前色,长按预设色则用被长按的色做基准微调
+    var colorPickerSeed by remember(state.selection, state.editing?.id) {
+        mutableStateOf(selectedColor)
+    }
     var menuVisible by remember(menuState) { mutableStateOf(false) }
     var menuVisible by remember(menuState) { mutableStateOf(false) }
     var closing by remember(menuState) { mutableStateOf(false) }
     var closing by remember(menuState) { mutableStateOf(false) }
 
 
@@ -207,7 +217,14 @@ fun MarkingSelectionMenu(
                                     style = selectedEffect.toStyle(color)
                                     style = selectedEffect.toStyle(color)
                                     onApply(style, note)
                                     onApply(style, note)
                                 },
                                 },
-                                onCustomColor = { showColorPicker = true },
+                                onColorLongPress = { color ->
+                                    colorPickerSeed = color
+                                    showColorPicker = true
+                                },
+                                onCustomColor = {
+                                    colorPickerSeed = selectedColor
+                                    showColorPicker = true
+                                },
                             )
                             )
 
 
                             if (useRules) {
                             if (useRules) {
@@ -275,7 +292,7 @@ fun MarkingSelectionMenu(
 
 
     ColorPickerSheet(
     ColorPickerSheet(
         show = showColorPicker,
         show = showColorPicker,
-        initialColor = selectedColor,
+        initialColor = colorPickerSeed,
         onDismissRequest = { showColorPicker = false },
         onDismissRequest = { showColorPicker = false },
         onColorSelected = { color ->
         onColorSelected = { color ->
             selectedColor = color
             selectedColor = color
@@ -287,6 +304,10 @@ fun MarkingSelectionMenu(
     )
     )
 }
 }
 
 
+/**
+ * 预设颜色行:点按选中,长按以该色为基准打开取色器微调(与尾部自定义色按钮一致)。
+ */
+@OptIn(ExperimentalFoundationApi::class)
 @Composable
 @Composable
 private fun MarkingColorRow(
 private fun MarkingColorRow(
     selectedColor: Int,
     selectedColor: Int,
@@ -294,6 +315,7 @@ private fun MarkingColorRow(
     enabled: Boolean,
     enabled: Boolean,
     onToggleRules: () -> Unit,
     onToggleRules: () -> Unit,
     onColorSelected: (Int) -> Unit,
     onColorSelected: (Int) -> Unit,
+    onColorLongPress: (Int) -> Unit,
     onCustomColor: () -> Unit,
     onCustomColor: () -> Unit,
 ) {
 ) {
     LazyRow(
     LazyRow(
@@ -310,13 +332,17 @@ private fun MarkingColorRow(
                 onClick = onToggleRules,
                 onClick = onToggleRules,
             )
             )
         }
         }
-        items(MarkingMenuColors, key = { it }) { color ->
+        items(MarkingPresetColors, key = { it }) { color ->
             Box(
             Box(
                 modifier = Modifier
                 modifier = Modifier
                     .size(26.dp)
                     .size(26.dp)
                     .clip(CircleShape)
                     .clip(CircleShape)
                     .background(Color(color))
                     .background(Color(color))
-                    .clickable(enabled = enabled) { onColorSelected(color) },
+                    .combinedClickable(
+                        enabled = enabled,
+                        onClick = { onColorSelected(color) },
+                        onLongClick = { onColorLongPress(color) },
+                    ),
                 contentAlignment = Alignment.Center,
                 contentAlignment = Alignment.Center,
             ) {
             ) {
                 if (color == selectedColor && !useRules) {
                 if (color == selectedColor && !useRules) {
@@ -483,8 +509,8 @@ private fun HighlightRule.toProcessStyle() = TextProcessStyle(
     underlineSvgPath = underlineSvgPath,
     underlineSvgPath = underlineSvgPath,
 )
 )
 
 
-private val MarkingMenuColors = listOf(
-    0xFFF44848.toInt(), 0xFF22C55E.toInt(), 0xFF3B82F6.toInt(),
-    0xFFA855F7.toInt(), 0xFFFF7417.toInt(), 0xFFEC4899.toInt(),
-    0xFF18B5A4.toInt(), 0xFF9A4D0F.toInt(), 0xFF111111.toInt(),
-)
+/**
+ * 标记卡(颜色行 + 效果格 + 备注框)的常规高度估算,含选择柄与间距余量。
+ * 仅用于推断缩放动画的展开方向,不参与实际落位。
+ */
+private val MARKING_MENU_ESTIMATED_HEIGHT = 240.dp

+ 11 - 18
app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt

@@ -633,6 +633,14 @@ private class MoreMenuPositionProvider(
     }
     }
 }
 }
 
 
+/**
+ * 选区浮层的落位规则:**默认贴着选区下沿展开**,并把整个选区与末端选择柄留在浮层之外。
+ * 只有选区贴近窗口底部、下方放不下卡片时才上翻,此时末行压在选区上沿;上下都放不下时
+ * 仍贴选区下沿,由末尾的 clamp 兜底。
+ *
+ * [TextActionSelectionMenu] 与 [MarkingSelectionMenu] 共用该规则:两者在同一次选区里互相
+ * 切换,落位必须一致,否则切换时浮层会跳。
+ */
 internal class TextMenuPositionProvider(
 internal class TextMenuPositionProvider(
     private val density: Float,
     private val density: Float,
     private val startX: Int,
     private val startX: Int,
@@ -641,7 +649,6 @@ internal class TextMenuPositionProvider(
     private val endX: Int,
     private val endX: Int,
     private val endBottomY: Int,
     private val endBottomY: Int,
     private val shadowPadding: Int,
     private val shadowPadding: Int,
-    private val placeOppositeHalf: Boolean = false,
 ) : PopupPositionProvider {
 ) : PopupPositionProvider {
     override fun calculatePosition(
     override fun calculatePosition(
         anchorBounds: IntRect,
         anchorBounds: IntRect,
@@ -659,27 +666,13 @@ internal class TextMenuPositionProvider(
         val cursorHandleClearance = (14 * density).toInt()
         val cursorHandleClearance = (14 * density).toInt()
 
 
         val cardHeight = popupContentSize.height - shadowPadding * 2
         val cardHeight = popupContentSize.height - shadowPadding * 2
-        val isSpaceEnoughAtTop = startTopY > cardHeight + textMargin + marginVertical
-        // 仅阅读区域最顶部 10% 的选区优先在下方展开;其余位置遵循原本“有上方空间
-        // 就放上方”的策略,避免菜单在普通位置不必要地遮挡后文。
-        val preferBelowForTopSelection = startTopY < windowSize.height / 10
         val isSpaceEnoughBelowSelection = windowSize.height - endBottomY >
         val isSpaceEnoughBelowSelection = windowSize.height - endBottomY >
                 cardHeight + textMargin + cursorHandleClearance + marginVertical
                 cardHeight + textMargin + cursorHandleClearance + marginVertical
+        val isSpaceEnoughAtTop = startTopY > cardHeight + textMargin + marginVertical
 
 
-        if (placeOppositeHalf) {
-            x = startX - shadowPadding
-            val selectionCenterY = (startTopY + endBottomY) / 2
-            y = if (selectionCenterY < windowSize.height / 2) {
-                endBottomY + cursorHandleClearance + textMargin - shadowPadding
-            } else {
-                startTopY - popupContentSize.height + shadowPadding - textMargin
-            }
-        } else if (!preferBelowForTopSelection && isSpaceEnoughAtTop) {
-            x = startX - shadowPadding
-            y = startTopY - popupContentSize.height + shadowPadding - textMargin
-        } else if (isSpaceEnoughBelowSelection) {
+        if (isSpaceEnoughBelowSelection) {
             x = startX - shadowPadding
             x = startX - shadowPadding
-            // 顶部选区没有空间放菜单时,必须避开整个选区及末端把手,不能只从首行下方弹出。
+            // 贴选区下沿展开,避开整个选区及末端把手,不能只从首行下方弹出。
             y = endBottomY + cursorHandleClearance + textMargin - shadowPadding
             y = endBottomY + cursorHandleClearance + textMargin - shadowPadding
         } else if (isSpaceEnoughAtTop) {
         } else if (isSpaceEnoughAtTop) {
             x = startX - shadowPadding
             x = startX - shadowPadding

+ 43 - 16
app/src/main/java/io/legado/app/ui/book/read/sheet/MarkingSheet.kt

@@ -1,8 +1,10 @@
 package io.legado.app.ui.book.read.sheet
 package io.legado.app.ui.book.read.sheet
 
 
+import androidx.compose.foundation.ExperimentalFoundationApi
 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
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
@@ -111,6 +113,8 @@ fun MarkingSheet(
         mutableStateOf(editingStyle?.underlineSvgPath)
         mutableStateOf(editingStyle?.underlineSvgPath)
     }
     }
     var showColorPicker by remember(show, editing) { mutableStateOf(false) }
     var showColorPicker by remember(show, editing) { mutableStateOf(false) }
+    // 打开取色器的起始色:点尾部按钮用当前色,长按预设色则用被长按的色做基准微调
+    var colorPickerSeed by remember(show, editing) { mutableStateOf(markColor) }
     val noteState = key(show, editing) {
     val noteState = key(show, editing) {
         rememberTextFieldState(initialText = editing?.note ?: "")
         rememberTextFieldState(initialText = editing?.note ?: "")
     }
     }
@@ -230,7 +234,14 @@ fun MarkingSheet(
                 MarkingColorRow(
                 MarkingColorRow(
                     selectedColor = markColor,
                     selectedColor = markColor,
                     onColorSelected = { markColor = it },
                     onColorSelected = { markColor = it },
-                    onCustomColorClick = { showColorPicker = true },
+                    onColorLongPress = { color ->
+                        colorPickerSeed = color
+                        showColorPicker = true
+                    },
+                    onCustomColorClick = {
+                        colorPickerSeed = markColor
+                        showColorPicker = true
+                    },
                 )
                 )
                 MarkingEffectGrid(
                 MarkingEffectGrid(
                     selectedEffect = effect,
                     selectedEffect = effect,
@@ -254,7 +265,7 @@ fun MarkingSheet(
 
 
     ColorPickerSheet(
     ColorPickerSheet(
         show = showColorPicker,
         show = showColorPicker,
-        initialColor = markColor,
+        initialColor = colorPickerSeed,
         onDismissRequest = { showColorPicker = false },
         onDismissRequest = { showColorPicker = false },
         onColorSelected = { color ->
         onColorSelected = { color ->
             markColor = color
             markColor = color
@@ -263,11 +274,15 @@ fun MarkingSheet(
     )
     )
 }
 }
 
 
-/** 自定义样式区:预设颜色行(尾部为自定义颜色,打开取色器)。 */
+/**
+ * 自定义样式区:预设颜色行(尾部为自定义颜色,打开取色器)。
+ * 点按预设色直接选中;长按预设色以该色为基准打开取色器微调,与尾部按钮一致。
+ */
 @Composable
 @Composable
 private fun MarkingColorRow(
 private fun MarkingColorRow(
     selectedColor: Int,
     selectedColor: Int,
     onColorSelected: (Int) -> Unit,
     onColorSelected: (Int) -> Unit,
+    onColorLongPress: (Int) -> Unit,
     onCustomColorClick: () -> Unit,
     onCustomColorClick: () -> Unit,
 ) {
 ) {
     Row(
     Row(
@@ -283,6 +298,7 @@ private fun MarkingColorRow(
                 color = color,
                 color = color,
                 selected = color == selectedColor,
                 selected = color == selectedColor,
                 onClick = { onColorSelected(color) },
                 onClick = { onColorSelected(color) },
+                onLongClick = { onColorLongPress(color) },
             )
             )
         }
         }
         // 尾部自定义颜色:未选自定义色时显示取色图标,选中后显示该色并高亮
         // 尾部自定义颜色:未选自定义色时显示取色图标,选中后显示该色并高亮
@@ -296,11 +312,13 @@ private fun MarkingColorRow(
     }
     }
 }
 }
 
 
+@OptIn(ExperimentalFoundationApi::class)
 @Composable
 @Composable
 private fun MarkingColorSwatch(
 private fun MarkingColorSwatch(
     color: Int?,
     color: Int?,
     selected: Boolean,
     selected: Boolean,
     onClick: () -> Unit,
     onClick: () -> Unit,
+    onLongClick: (() -> Unit)? = null,
     custom: Boolean = false,
     custom: Boolean = false,
 ) {
 ) {
     val borderColor = if (selected) {
     val borderColor = if (selected) {
@@ -316,7 +334,7 @@ private fun MarkingColorSwatch(
                 if (color != null) Color(color) else LegadoTheme.colorScheme.surfaceContainerHigh
                 if (color != null) Color(color) else LegadoTheme.colorScheme.surfaceContainerHigh
             )
             )
             .border(2.dp, borderColor, CircleShape)
             .border(2.dp, borderColor, CircleShape)
-            .clickable(onClick = onClick),
+            .combinedClickable(onClick = onClick, onLongClick = onLongClick),
         contentAlignment = Alignment.Center,
         contentAlignment = Alignment.Center,
     ) {
     ) {
         if (custom && color == null) {
         if (custom && color == null) {
@@ -424,18 +442,27 @@ private fun HighlightRule.toProcessStyle(): TextProcessStyle = TextProcessStyle(
     underlineSvgPath = underlineSvgPath,
     underlineSvgPath = underlineSvgPath,
 )
 )
 
 
-/** 自定义模式的预设颜色(尾部之外的自定义色用取色器)。 */
-private val MarkingPresetColors = listOf(
-    0xFFFF5252.toInt(),
-    0xFFFF9800.toInt(),
-    0xFFFFEB3B.toInt(),
-    0xFF4CAF50.toInt(),
-    0xFF26A6D6.toInt(),
-    0xFF2196F3.toInt(),
-    0xFF9C27B0.toInt(),
-    0xFFEC407A.toInt(),
-    0xFF795548.toInt(),
-    0xFF607D8B.toInt(),
+/**
+ * 划线/高亮自定义模式的预设颜色,内联编辑悬浮窗([io.legado.app.ui.book.read.MarkingSelectionMenu])
+ * 与本 Sheet 共用一套,避免两处色板漂移;更精细的颜色由尾部取色按钮打开取色器选择。
+ */
+internal val MarkingPresetColors = listOf(
+    0xFFF44848.toInt(), // 红
+    0xFFFF7417.toInt(), // 橙
+    0xFFFFC107.toInt(), // 琥珀
+    0xFFFFEB3B.toInt(), // 黄
+    0xFF9CCC65.toInt(), // 黄绿
+    0xFF22C55E.toInt(), // 绿
+    0xFF18B5A4.toInt(), // 青绿
+    0xFF26C6DA.toInt(), // 青
+    0xFF3B82F6.toInt(), // 蓝
+    0xFF5C6BC0.toInt(), // 靛蓝
+    0xFF9C27B0.toInt(), // 紫
+    0xFFA855F7.toInt(), // 亮紫
+    0xFFEC4899.toInt(), // 粉
+    0xFF9A4D0F.toInt(), // 棕
+    0xFF607D8B.toInt(), // 蓝灰
+    0xFF111111.toInt(), // 黑
 )
 )
 
 
 private const val STYLE_SOURCE_RULE = "rule"
 private const val STYLE_SOURCE_RULE = "rule"

+ 11 - 2
app/src/test/java/io/legado/app/domain/model/MarkingEffectTest.kt

@@ -24,16 +24,25 @@ class MarkingEffectTest {
     }
     }
 
 
     @Test
     @Test
-    fun `效果到样式 - 删除线与半高荧光使用独立绘制模式`() {
+    fun `效果到样式 - 删除线与半高荧光使用独立绘制模式且荧光不附加默认透明度`() {
         val color = 0xFFFFD54F.toInt()
         val color = 0xFFFFD54F.toInt()
         assertEquals(6, MarkingEffect.STRIKE.toStyle(color).underlineMode)
         assertEquals(6, MarkingEffect.STRIKE.toStyle(color).underlineMode)
         val highlight = MarkingEffect.HIGHLIGHT.toStyle(color)
         val highlight = MarkingEffect.HIGHLIGHT.toStyle(color)
         assertEquals(7, highlight.underlineMode)
         assertEquals(7, highlight.underlineMode)
-        assertEquals(0x66FFD54F, highlight.underlineColor)
+        assertEquals(0xFFFFD54F.toInt(), highlight.underlineColor)
         assertTrue(MarkingEffect.STRIKE.isUnderline)
         assertTrue(MarkingEffect.STRIKE.isUnderline)
         assertTrue(MarkingEffect.HIGHLIGHT.isUnderline)
         assertTrue(MarkingEffect.HIGHLIGHT.isUnderline)
     }
     }
 
 
+    @Test
+    fun `效果到样式 - 荧光保留所选颜色的透明度`() {
+        val translucent = 0x40FFD54F.toInt()
+        assertEquals(
+            translucent,
+            MarkingEffect.HIGHLIGHT.toStyle(translucent).underlineColor,
+        )
+    }
+
     @Test
     @Test
     fun `效果到样式 - 背景色自动加 ~20% 透明度`() {
     fun `效果到样式 - 背景色自动加 ~20% 透明度`() {
         val style = MarkingEffect.BG.toStyle(0xFFFFD54F.toInt())
         val style = MarkingEffect.BG.toStyle(0xFFFFD54F.toInt())