diff --git a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt index 94f9f03..3c626d7 100644 --- a/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/PhotoEditorViewModel.kt @@ -80,14 +80,43 @@ class PhotoEditorViewModel( update { it.copy(rotationDegrees = it.rotationDegrees + delta) } } - /** 开启/关闭裁剪。开启时使用默认居中 4:3 裁剪框。 */ - fun toggleCrop() { + /** 进入裁剪模式:启用裁剪框并重置为默认居中区域。 */ + fun enterCrop() { update { - if (it.cropEnabled) { - it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f) - } else { - it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f) - } + it.copy(cropLeft = 0.1f, cropTop = 0.1f, cropRight = 0.9f, cropBottom = 0.9f) + } + } + + /** 退出裁剪模式:清空裁剪框(不应用裁剪)。 */ + fun exitCrop() { + update { + it.copy(cropLeft = null, cropRight = null, cropTop = 0f, cropBottom = 1f) + } + } + + /** + * 确认裁剪:将当前裁剪框烘焙到源图,用裁剪结果替换 [PhotoEditorState.sourceBitmap], + * 重置旋转与裁剪框,清空手写笔触(坐标系已变化)。 + */ + fun applyCrop() { + update { + if (!it.cropEnabled) return@update it + val cropped = PhotoProcessor.crop( + bitmap = it.rotatedBitmap, + left = it.cropLeft!!, + top = it.cropTop, + right = it.cropRight!!, + bottom = it.cropBottom + ) + it.copy( + sourceBitmap = cropped, + rotationDegrees = 0, + cropLeft = null, + cropTop = 0f, + cropRight = null, + cropBottom = 1f, + strokes = emptyList() + ) } } diff --git a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt index 71608e2..9c3325f 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.Crop -import androidx.compose.material.icons.filled.CropFree import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.RotateLeft import androidx.compose.material.icons.filled.RotateRight @@ -45,6 +44,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import kotlin.math.absoluteValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -95,6 +95,14 @@ fun PhotoEditorScreen( var activeTab by remember { mutableStateOf(EditTab.ROTATE) } val savedPath = state.savedPath + // 切到裁剪 tab 自动进入裁剪模式;切走则退出(不应用裁剪) + LaunchedEffect(activeTab, state.editorState) { + when (activeTab) { + EditTab.CROP -> viewModel.enterCrop() + else -> viewModel.exitCrop() + } + } + if (savedPath != null) { LaunchedEffect(savedPath) { onSaved(savedPath) } } @@ -157,7 +165,7 @@ fun PhotoEditorScreen( activeTab = activeTab, onTabChange = { activeTab = it }, onRotate = viewModel::rotate, - onCropToggle = viewModel::toggleCrop, + onApplyCrop = viewModel::applyCrop, onCropChange = viewModel::updateCrop, onAddPoint = viewModel::addStrokePoint, onEndStroke = viewModel::endStroke, @@ -181,7 +189,7 @@ private fun EditorBody( activeTab: EditTab, onTabChange: (EditTab) -> Unit, onRotate: (Int) -> Unit, - onCropToggle: () -> Unit, + onApplyCrop: () -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit, onAddPoint: (Offset) -> Unit, onEndStroke: () -> Unit, @@ -245,7 +253,7 @@ private fun EditorBody( mode = activeTab, state = state, onRotate = onRotate, - onCropToggle = onCropToggle, + onApplyCrop = onApplyCrop, onUndoStroke = onUndoStroke, onStrokeColorChange = onStrokeColorChange, modifier = Modifier @@ -265,8 +273,6 @@ private fun EditableImage( onDisplaySizeChange: (Float, Float) -> Unit ) { val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap } - // 裁剪框拖动手柄(CROP 模式下使用) - var dragHandle by remember { mutableStateOf(null) } Box( modifier = Modifier @@ -299,8 +305,6 @@ private fun EditableImage( if (mode == EditTab.CROP && state.cropEnabled) { CropOverlay( state = state, - dragHandle = dragHandle, - onHandleChange = { dragHandle = it }, onCropChange = onCropChange, modifier = Modifier.fillMaxSize() ) @@ -324,7 +328,7 @@ private fun EditableImage( } } -private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, NONE } +private enum class CropHandle { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, BODY, NONE } /** 裁剪框四元组,支持解构以简化 onDrag 回传。 */ private data class CropRect( @@ -334,16 +338,110 @@ private data class CropRect( val bottom: Float ) +/** + * 判断触摸点落在裁剪框的哪个区域,用于决定拖动行为。 + * + * - 落在四角阈值范围内 → 对应角手柄(缩放裁剪框) + * - 落在框内 → 整体平移 + * - 落在框外 → [CropHandle.NONE],不响应拖动 + * + * @param ox 触摸点 x 比例坐标 [0,1] + * @param oy 触摸点 y 比例坐标 [0,1] + * @param l/t/r/b 当前裁剪框四边比例 + */ +private fun hitHandle( + ox: Float, + oy: Float, + l: Float, + t: Float, + r: Float, + b: Float +): CropHandle { + val corner = 0.1f + val atTopLeft = (ox - l).absoluteValue < corner && (oy - t).absoluteValue < corner + val atTopRight = (ox - r).absoluteValue < corner && (oy - t).absoluteValue < corner + val atBottomLeft = (ox - l).absoluteValue < corner && (oy - b).absoluteValue < corner + val atBottomRight = (ox - r).absoluteValue < corner && (oy - b).absoluteValue < corner + return when { + atTopLeft -> CropHandle.TOP_LEFT + atTopRight -> CropHandle.TOP_RIGHT + atBottomLeft -> CropHandle.BOTTOM_LEFT + atBottomRight -> CropHandle.BOTTOM_RIGHT + ox in l..r && oy in t..b -> CropHandle.BODY + else -> CropHandle.NONE + } +} + +/** + * 将单次拖动增量应用到当前裁剪框,返回受边界约束的新四元组。 + * + * - 角手柄:只移动对应角,并用 [minSize] 约束避免左右/上下交叉 + * - [CropHandle.BODY]:整体平移,受 [0,1] 边界约束 + * + * @param dx x 方向增量比例(已归一化) + * @param dy y 方向增量比例(已归一化) + * @param l/t/r/b 当前裁剪框四边比例 + * @param handle 本次拖动的手柄 + * @param minSize 裁剪框最小宽/高比例,防止四角交叉 + */ +private fun moveCrop( + dx: Float, + dy: Float, + l: Float, + t: Float, + r: Float, + b: Float, + handle: CropHandle, + minSize: Float = 0.1f +): CropRect { + val left = l.coerceIn(0f, 1f) + val top = t.coerceIn(0f, 1f) + val right = r.coerceIn(0f, 1f) + val bottom = b.coerceIn(0f, 1f) + return when (handle) { + CropHandle.TOP_LEFT -> { + val nl = (left + dx).coerceIn(0f, right - minSize) + val nt = (top + dy).coerceIn(0f, bottom - minSize) + CropRect(nl, nt, right, bottom) + } + CropHandle.TOP_RIGHT -> { + val nr = (right + dx).coerceIn(left + minSize, 1f) + val nt = (top + dy).coerceIn(0f, bottom - minSize) + CropRect(left, nt, nr, bottom) + } + CropHandle.BOTTOM_LEFT -> { + val nl = (left + dx).coerceIn(0f, right - minSize) + val nb = (bottom + dy).coerceIn(top + minSize, 1f) + CropRect(nl, top, right, nb) + } + CropHandle.BOTTOM_RIGHT -> { + val nr = (right + dx).coerceIn(left + minSize, 1f) + val nb = (bottom + dy).coerceIn(top + minSize, 1f) + CropRect(left, top, nr, nb) + } + CropHandle.BODY -> { + val w = right - left + val h = bottom - top + val nl = (left + dx).coerceIn(0f, 1f - w) + val nt = (top + dy).coerceIn(0f, 1f - h) + CropRect(nl, nt, nl + w, nt + h) + } + CropHandle.NONE -> CropRect(left, top, right, bottom) + } +} + @Composable private fun CropOverlay( state: PhotoEditorState, - dragHandle: CropHandle?, - onHandleChange: (CropHandle?) -> Unit, onCropChange: (Float, Float, Float, Float) -> Unit, modifier: Modifier = Modifier ) { + // 拖动手柄内聚在此组件,避免跨重组延迟 + var dragHandle by remember { mutableStateOf(CropHandle.NONE) } val left = state.cropLeft ?: 0.1f + val top = state.cropTop val right = state.cropRight ?: 0.9f + val bottom = state.cropBottom Canvas( modifier = modifier.pointerInput(Unit) { detectDragGestures( @@ -351,45 +449,42 @@ private fun CropOverlay( val size = this.size val ox = offset.x / size.width val oy = offset.y / size.height - onHandleChange( - when { - ox < 0.2f && oy < 0.2f -> CropHandle.TOP_LEFT - ox > 0.8f && oy < 0.2f -> CropHandle.TOP_RIGHT - ox < 0.2f && oy > 0.8f -> CropHandle.BOTTOM_LEFT - ox > 0.8f && oy > 0.8f -> CropHandle.BOTTOM_RIGHT - else -> CropHandle.NONE - } + dragHandle = hitHandle( + ox, oy, + state.cropLeft ?: 0.1f, + state.cropTop, + state.cropRight ?: 0.9f, + state.cropBottom ) }, onDrag = { change, drag -> + if (dragHandle == CropHandle.NONE) return@detectDragGestures change.consume() val size = this.size val dx = drag.x / size.width val dy = drag.y / size.height - val (nL, nT, nR, nB) = when (dragHandle) { - CropHandle.TOP_LEFT -> - CropRect(left + dx, state.cropTop + dy, right, state.cropBottom) - CropHandle.TOP_RIGHT -> - CropRect(left, state.cropTop + dy, right + dx, state.cropBottom) - CropHandle.BOTTOM_LEFT -> - CropRect(left + dx, state.cropTop, right, state.cropBottom + dy) - CropHandle.BOTTOM_RIGHT -> - CropRect(left, state.cropTop, right + dx, state.cropBottom + dy) - else -> return@detectDragGestures - } - onCropChange(nL, nT, nR, nB) + // 每帧从 state 读最新值,叠加增量 + val cur = moveCrop( + dx, dy, + state.cropLeft ?: 0.1f, + state.cropTop, + state.cropRight ?: 0.9f, + state.cropBottom, + dragHandle + ) + onCropChange(cur.left, cur.top, cur.right, cur.bottom) }, - onDragEnd = { onHandleChange(null) }, - onDragCancel = { onHandleChange(null) } + onDragEnd = { dragHandle = CropHandle.NONE }, + onDragCancel = { dragHandle = CropHandle.NONE } ) } ) { val w = size.width val h = size.height val l = left * w - val t = state.cropTop * h + val t = top * h val r = right * w - val b = state.cropBottom * h + val b = bottom * h // 四周遮罩 drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(0f, 0f), size = Size(l, h)) drawRect(Color.Black.copy(alpha = 0.5f), topLeft = Offset(r, 0f), size = Size(w - r, h)) @@ -410,7 +505,7 @@ private fun ToolBar( mode: EditTab, state: PhotoEditorState, onRotate: (Int) -> Unit, - onCropToggle: () -> Unit, + onApplyCrop: () -> Unit, onUndoStroke: () -> Unit, onStrokeColorChange: (Color) -> Unit, modifier: Modifier = Modifier @@ -430,11 +525,8 @@ private fun ToolBar( } } EditTab.CROP -> { - FilledIconButton(onClick = onCropToggle) { - Icon( - if (state.cropEnabled) Icons.Filled.CropFree else Icons.Filled.Crop, - contentDescription = if (state.cropEnabled) "关闭裁剪" else "开启裁剪" - ) + FilledIconButton(onClick = onApplyCrop) { + Icon(Icons.Filled.Done, contentDescription = "确认裁剪") } } EditTab.HANDWRITE -> {