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:
xfy 2026-07-17 16:01:44 +08:00
parent 50da18460f
commit 81aeb8e894
3 changed files with 142 additions and 13 deletions

View File

@ -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
}

View File

@ -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() }
}
/** 撤销最近一条笔触。 */

View 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
}
}
}