fix(date-recorder): split handwriting strokes on finger lift
endStroke() was a no-op, so addStrokePoint() kept appending every new pointer-down to the previous stroke (its only segmentation cue was points.isEmpty()). A second drag after lift got joined into the first, and toPath() drew them as a single polyline. Add isFinished flag to HandStroke and implement real endStroke() that marks the current stroke finished. withAddedPoint() now starts a new segment when the previous stroke is null, empty, or finished.
This commit is contained in:
parent
50da18460f
commit
81aeb8e894
@ -14,11 +14,14 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
* @param color 笔触颜色
|
||||
* @param widthPx 笔触线宽(像素)
|
||||
* @param points 采样点序列(基于图片显示坐标系)
|
||||
* @param isFinished 该笔触是否已结束(手指已抬起)。下一条笔触必须另起新段,
|
||||
* 不能再追加到 [points] 上;否则两次手写会被 [toPath] 用一条折线连在一起。
|
||||
*/
|
||||
data class HandStroke(
|
||||
val color: Color,
|
||||
val widthPx: Float,
|
||||
val points: List<Offset> = emptyList()
|
||||
val points: List<Offset> = emptyList(),
|
||||
val isFinished: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
@ -80,3 +83,30 @@ fun HandStroke.toPath(): Path {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前状态下追加一个手写采样点,返回新状态。
|
||||
*
|
||||
* 若上一条笔触不存在、为空、或已结束([HandStroke.isFinished]),则另起新段;
|
||||
* 否则把点追加到上一条笔触的末尾。
|
||||
*/
|
||||
fun PhotoEditorState.withAddedPoint(offset: Offset): PhotoEditorState {
|
||||
val last = strokes.lastOrNull()
|
||||
val newStrokes = if (last == null || last.points.isEmpty() || last.isFinished) {
|
||||
strokes + HandStroke(strokeColor, strokeWidthPx, listOf(offset))
|
||||
} else {
|
||||
strokes.dropLast(1) + last.copy(points = last.points + offset)
|
||||
}
|
||||
return copy(strokes = newStrokes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束当前正在绘制的笔触,返回新状态。若没有进行中的笔触则原样返回。
|
||||
*/
|
||||
fun PhotoEditorState.withEndedStroke(): PhotoEditorState {
|
||||
val last = strokes.lastOrNull() ?: return this
|
||||
if (!last.isFinished && last.points.isNotEmpty()) {
|
||||
return copy(strokes = strokes.dropLast(1) + last.copy(isFinished = true))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
@ -105,22 +105,12 @@ class PhotoEditorViewModel(
|
||||
|
||||
/** 追加一个手写笔触采样点。 */
|
||||
fun addStrokePoint(offset: Offset) {
|
||||
update {
|
||||
val current = it.strokeColor to it.strokeWidthPx
|
||||
val newStrokes = if (it.strokes.isEmpty() || it.strokes.last().points.isEmpty()) {
|
||||
it.strokes + HandStroke(current.first, current.second, listOf(offset))
|
||||
} else {
|
||||
val last = it.strokes.last()
|
||||
it.strokes.dropLast(1) + last.copy(points = last.points + offset)
|
||||
}
|
||||
it.copy(strokes = newStrokes)
|
||||
}
|
||||
update { it.withAddedPoint(offset) }
|
||||
}
|
||||
|
||||
/** 结束当前笔触(抬起手指)。 */
|
||||
fun endStroke() {
|
||||
// 当前实现笔触在 addStrokePoint 中即时累积,无需额外处理;
|
||||
// 保留方法以便未来区分笔触段(如双击结束)。
|
||||
update { it.withEndedStroke() }
|
||||
}
|
||||
|
||||
/** 撤销最近一条笔触。 */
|
||||
|
||||
109
core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt
Normal file
109
core/src/test/kotlin/plus/rua/project/HandStrokeTest.kt
Normal file
@ -0,0 +1,109 @@
|
||||
package plus.rua.project
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import sun.misc.Unsafe
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* 手写笔触分段逻辑单元测试。
|
||||
*
|
||||
* 覆盖 [PhotoEditorState.withAddedPoint] / [PhotoEditorState.withEndedStroke]:
|
||||
* 抬手后再次落笔必须另起新段,不能与上一条笔触连成一条折线。
|
||||
*/
|
||||
class HandStrokeTest {
|
||||
|
||||
private fun emptyState() = PhotoEditorState(
|
||||
sourceBitmap = UninitializedBitmap,
|
||||
sourceAbsolutePath = "/tmp/fake.jpg"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun twoStrokes_afterLiftAreSeparate() {
|
||||
// 画第一笔 A→B
|
||||
var s = emptyState()
|
||||
.withAddedPoint(Offset(0f, 0f))
|
||||
.withAddedPoint(Offset(10f, 0f))
|
||||
// 抬手
|
||||
s = s.withEndedStroke()
|
||||
// 在别处落笔 C→D
|
||||
s = s.withAddedPoint(Offset(100f, 100f))
|
||||
.withAddedPoint(Offset(110f, 100f))
|
||||
|
||||
// 期望:两条独立笔触,而不是 [A,B,C,D] 一条
|
||||
assertEquals(2, s.strokes.size, "两笔应分隔为两段")
|
||||
assertEquals(listOf(Offset(0f, 0f), Offset(10f, 0f)), s.strokes[0].points)
|
||||
assertEquals(listOf(Offset(100f, 100f), Offset(110f, 100f)), s.strokes[1].points)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun points_accumulateWithinSameStroke() {
|
||||
val s = emptyState()
|
||||
.withAddedPoint(Offset(1f, 1f))
|
||||
.withAddedPoint(Offset(2f, 2f))
|
||||
.withAddedPoint(Offset(3f, 3f))
|
||||
|
||||
assertEquals(1, s.strokes.size)
|
||||
assertEquals(
|
||||
listOf(Offset(1f, 1f), Offset(2f, 2f), Offset(3f, 3f)),
|
||||
s.strokes[0].points
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun endStroke_marksCurrentStrokeFinished() {
|
||||
val s = emptyState()
|
||||
.withAddedPoint(Offset(0f, 0f))
|
||||
.withEndedStroke()
|
||||
|
||||
assertEquals(1, s.strokes.size)
|
||||
assertTrue(s.strokes[0].isFinished, "抬手后笔触应标记为已结束")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun endStroke_withNoStrokes_isNoop() {
|
||||
val s = emptyState().withEndedStroke()
|
||||
assertTrue(s.strokes.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun endStroke_calledTwice_keepsStrokeFinished() {
|
||||
val s = emptyState()
|
||||
.withAddedPoint(Offset(0f, 0f))
|
||||
.withEndedStroke()
|
||||
.withEndedStroke()
|
||||
|
||||
assertEquals(1, s.strokes.size)
|
||||
assertTrue(s.strokes[0].isFinished)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addPoint_afterFinished_startsNewStroke() {
|
||||
// 抬手之后再落点:上一条 isFinished=true,应另起新段
|
||||
val s = emptyState()
|
||||
.withAddedPoint(Offset(0f, 0f))
|
||||
.withEndedStroke()
|
||||
.withAddedPoint(Offset(50f, 50f))
|
||||
|
||||
assertEquals(2, s.strokes.size)
|
||||
assertFalse(s.strokes[0].points.contains(Offset(50f, 50f)))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* 通过 Unsafe.allocateInstance 创建 Bitmap 桩,跳过 Android 框架的静态初始化。
|
||||
* 笔触分段逻辑不读 Bitmap 任何字段/方法,桩仅用于满足 PhotoEditorState 构造器。
|
||||
*/
|
||||
@Suppress("DiscouragedPrivateApi", "DEPRECATION")
|
||||
val UninitializedBitmap: android.graphics.Bitmap by lazy {
|
||||
val unsafeField = Unsafe::class.java.getDeclaredField("theUnsafe").apply { isAccessible = true }
|
||||
val unsafe = unsafeField.get(null) as Unsafe
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val bmp = unsafe.allocateInstance(android.graphics.Bitmap::class.java)
|
||||
as android.graphics.Bitmap
|
||||
bmp
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user