Fix line styles, move tool selection, stroke style storage
Line styles: - Store stroke style in DB (style column, migration v2->v3) - Dashed lines rendered with DashPathEffect on the paint - Arrow heads drawn dynamically from stored two-point line data - Line tool stores just two endpoints; style determines rendering - Removed buildLinePoints — arrow heads no longer baked into point data Move tool: - If there's an active selection, Move drags the entire selection - Otherwise, hit-tests a single stroke and drags it - Selection stays highlighted after move completes Line dropdown: - Removed conflicting onClick from Surface, combinedClickable now correctly handles both tap (select tool) and long-press (style menu) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import net.metacircular.engpad.data.model.Stroke
|
||||
|
||||
@Database(
|
||||
entities = [Notebook::class, Page::class, Stroke::class],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class EngPadDatabase : RoomDatabase() {
|
||||
@@ -32,13 +32,21 @@ abstract class EngPadDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"ALTER TABLE strokes ADD COLUMN style TEXT NOT NULL DEFAULT 'plain'"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): EngPadDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
EngPadDatabase::class.java,
|
||||
"engpad.db",
|
||||
).addMigrations(MIGRATION_1_2).build().also { instance = it }
|
||||
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ import androidx.room.PrimaryKey
|
||||
],
|
||||
indices = [Index(value = ["page_id"])],
|
||||
)
|
||||
/**
|
||||
* Style constants stored in the `style` column.
|
||||
* - "plain" — solid line (default, freehand strokes, boxes)
|
||||
* - "dashed" — dashed line
|
||||
* - "arrow" — solid line with arrow at end
|
||||
* - "double_arrow" — solid line with arrows at both ends
|
||||
*/
|
||||
data class Stroke(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@ColumnInfo(name = "page_id") val pageId: Long,
|
||||
@@ -26,6 +33,7 @@ data class Stroke(
|
||||
@ColumnInfo(name = "point_data") val pointData: ByteArray,
|
||||
@ColumnInfo(name = "stroke_order") val strokeOrder: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
@ColumnInfo(name = "style", defaultValue = "plain") val style: String = STYLE_PLAIN,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@@ -36,7 +44,8 @@ data class Stroke(
|
||||
color == other.color &&
|
||||
pointData.contentEquals(other.pointData) &&
|
||||
strokeOrder == other.strokeOrder &&
|
||||
createdAt == other.createdAt
|
||||
createdAt == other.createdAt &&
|
||||
style == other.style
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -47,6 +56,14 @@ data class Stroke(
|
||||
result = 31 * result + pointData.contentHashCode()
|
||||
result = 31 * result + strokeOrder
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
result = 31 * result + style.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STYLE_PLAIN = "plain"
|
||||
const val STYLE_DASHED = "dashed"
|
||||
const val STYLE_ARROW = "arrow"
|
||||
const val STYLE_DOUBLE_ARROW = "double_arrow"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ fun EditorScreen(
|
||||
|
||||
// Wire up callbacks
|
||||
LaunchedEffect(Unit) {
|
||||
canvasView.onStrokeCompleted = { penSize, color, points ->
|
||||
viewModel.onStrokeCompleted(penSize, color, points)
|
||||
canvasView.onStrokeCompleted = { penSize, color, points, style ->
|
||||
viewModel.onStrokeCompleted(penSize, color, points, style)
|
||||
}
|
||||
canvasView.onZoomPanChanged = { zoom, panX, panY ->
|
||||
viewModel.onZoomPanChanged(zoom, panX, panY)
|
||||
@@ -92,7 +92,7 @@ fun EditorScreen(
|
||||
viewModel.onStrokeAdded = { stroke ->
|
||||
canvasView.addCompletedStroke(
|
||||
stroke.id, stroke.penSize, stroke.color,
|
||||
stroke.pointData.toFloatArray(),
|
||||
stroke.pointData.toFloatArray(), stroke.style,
|
||||
)
|
||||
}
|
||||
viewModel.onStrokeRemoved = { id ->
|
||||
|
||||
@@ -152,7 +152,7 @@ class EditorViewModel(
|
||||
_canvasState.value = _canvasState.value.copy(zoom = zoom, panX = panX, panY = panY)
|
||||
}
|
||||
|
||||
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
|
||||
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray, style: String = Stroke.STYLE_PLAIN) {
|
||||
viewModelScope.launch {
|
||||
val pageId = _currentPageId.value
|
||||
val order = pageRepository.getNextStrokeOrder(pageId)
|
||||
@@ -163,6 +163,7 @@ class EditorViewModel(
|
||||
pointData = points.toBlob(),
|
||||
strokeOrder = order,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
style = style,
|
||||
)
|
||||
undoManager.perform(
|
||||
AddStrokeAction(
|
||||
|
||||
@@ -84,7 +84,7 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
private val inverseMatrix = Matrix()
|
||||
|
||||
// --- Callbacks ---
|
||||
var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null
|
||||
var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray, style: String) -> Unit)? = null
|
||||
var onZoomPanChanged: ((zoom: Float, panX: Float, panY: Float) -> Unit)? = null
|
||||
var onStrokeErased: ((strokeId: Long) -> Unit)? = null
|
||||
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
|
||||
@@ -93,7 +93,8 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
var onStrokeMoved: ((strokeId: Long, deltaX: Float, deltaY: Float) -> Unit)? = null
|
||||
|
||||
// --- Move tool state ---
|
||||
private var movingStrokeId: Long? = null
|
||||
private var isMoving = false
|
||||
private var movingIds = mutableSetOf<Long>()
|
||||
private var moveLastX = 0f
|
||||
private var moveLastY = 0f
|
||||
private var moveOriginX = 0f
|
||||
@@ -199,16 +200,16 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
for (stroke in strokes) {
|
||||
val points = stroke.pointData.toFloatArray()
|
||||
val path = buildPathFromPoints(points)
|
||||
val paint = buildPaint(stroke.penSize, stroke.color)
|
||||
completedStrokes.add(StrokeRender(path, paint, stroke.id))
|
||||
val paint = buildPaint(stroke.penSize, stroke.color, stroke.style)
|
||||
completedStrokes.add(StrokeRender(path, paint, stroke.id, stroke.style, points))
|
||||
}
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun addCompletedStroke(id: Long, penSize: Float, color: Int, points: FloatArray) {
|
||||
fun addCompletedStroke(id: Long, penSize: Float, color: Int, points: FloatArray, style: String = Stroke.STYLE_PLAIN) {
|
||||
val path = buildPathFromPoints(points)
|
||||
val paint = buildPaint(penSize, color)
|
||||
completedStrokes.add(StrokeRender(path, paint, id))
|
||||
val paint = buildPaint(penSize, color, style)
|
||||
completedStrokes.add(StrokeRender(path, paint, id, style, points))
|
||||
invalidate()
|
||||
}
|
||||
|
||||
@@ -245,6 +246,19 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
// Completed strokes
|
||||
for (sr in completedStrokes) {
|
||||
drawPath(sr.path, sr.paint)
|
||||
// Draw arrow heads for arrow-style strokes
|
||||
val pts = sr.points
|
||||
if (pts != null && pts.size >= 4) {
|
||||
val x1 = pts[0]
|
||||
val y1 = pts[1]
|
||||
val x2 = pts[2]
|
||||
val y2 = pts[3]
|
||||
if (sr.style == Stroke.STYLE_ARROW || sr.style == Stroke.STYLE_DOUBLE_ARROW) {
|
||||
drawArrowHeads(this, x1, y1, x2, y2,
|
||||
if (sr.style == Stroke.STYLE_ARROW) LineStyle.ARROW else LineStyle.DOUBLE_ARROW,
|
||||
sr.paint.strokeWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In-progress stroke
|
||||
@@ -465,7 +479,7 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
isSnappedToLine = false
|
||||
if (currentPoints.size >= 4) {
|
||||
val points = currentPoints.toFloatArray()
|
||||
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
|
||||
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points, Stroke.STYLE_PLAIN)
|
||||
}
|
||||
currentPath = null
|
||||
currentPaint = null
|
||||
@@ -537,7 +551,7 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
left, bottom,
|
||||
left, top, // close the rectangle
|
||||
)
|
||||
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
|
||||
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points, Stroke.STYLE_PLAIN)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
@@ -551,42 +565,59 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
val pt = screenToCanonical(event.x, event.y)
|
||||
val hitId = hitTestStroke(pt[0], pt[1])
|
||||
if (hitId != null) {
|
||||
movingStrokeId = hitId
|
||||
moveLastX = pt[0]
|
||||
moveLastY = pt[1]
|
||||
moveOriginX = pt[0]
|
||||
moveOriginY = pt[1]
|
||||
// Highlight the stroke being moved
|
||||
selectedStrokeIds.clear()
|
||||
selectedStrokeIds.add(hitId)
|
||||
invalidate()
|
||||
moveLastX = pt[0]
|
||||
moveLastY = pt[1]
|
||||
moveOriginX = pt[0]
|
||||
moveOriginY = pt[1]
|
||||
|
||||
if (selectedStrokeIds.isNotEmpty()) {
|
||||
// Move the existing selection
|
||||
movingIds.clear()
|
||||
movingIds.addAll(selectedStrokeIds)
|
||||
isMoving = true
|
||||
} else {
|
||||
// Hit-test a single stroke to grab
|
||||
val hitId = hitTestStroke(pt[0], pt[1])
|
||||
if (hitId != null) {
|
||||
movingIds.clear()
|
||||
movingIds.add(hitId)
|
||||
selectedStrokeIds.add(hitId)
|
||||
isMoving = true
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val id = movingStrokeId ?: return true
|
||||
if (!isMoving) return true
|
||||
val pt = screenToCanonical(event.x, event.y)
|
||||
val dx = pt[0] - moveLastX
|
||||
val dy = pt[1] - moveLastY
|
||||
// Offset the path visually
|
||||
completedStrokes.find { it.id == id }?.path?.offset(dx, dy)
|
||||
for (sr in completedStrokes) {
|
||||
if (sr.id in movingIds) {
|
||||
sr.path.offset(dx, dy)
|
||||
}
|
||||
}
|
||||
moveLastX = pt[0]
|
||||
moveLastY = pt[1]
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val id = movingStrokeId ?: return true
|
||||
if (!isMoving) return true
|
||||
val pt = screenToCanonical(event.x, event.y)
|
||||
val totalDx = pt[0] - moveOriginX
|
||||
val totalDy = pt[1] - moveOriginY
|
||||
movingStrokeId = null
|
||||
selectedStrokeIds.clear()
|
||||
isMoving = false
|
||||
val ids = movingIds.toSet()
|
||||
if (Math.abs(totalDx) > 1f || Math.abs(totalDy) > 1f) {
|
||||
onStrokeMoved?.invoke(id, totalDx, totalDy)
|
||||
if (ids.size == 1) {
|
||||
onStrokeMoved?.invoke(ids.first(), totalDx, totalDy)
|
||||
} else {
|
||||
onSelectionMoved?.invoke(ids, totalDx, totalDy)
|
||||
}
|
||||
}
|
||||
// Keep selection highlighted after move
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
@@ -617,12 +648,15 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
isDrawingLine = false
|
||||
// Build the line stroke as points
|
||||
val points = buildLinePoints(
|
||||
lineStartX, lineStartY, lineEndX, lineEndY,
|
||||
canvasState.lineStyle,
|
||||
)
|
||||
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
|
||||
val lineStyle = canvasState.lineStyle
|
||||
val points = floatArrayOf(lineStartX, lineStartY, lineEndX, lineEndY)
|
||||
val strokeStyle = when (lineStyle) {
|
||||
LineStyle.PLAIN -> Stroke.STYLE_PLAIN
|
||||
LineStyle.ARROW -> Stroke.STYLE_ARROW
|
||||
LineStyle.DOUBLE_ARROW -> Stroke.STYLE_DOUBLE_ARROW
|
||||
LineStyle.DASHED -> Stroke.STYLE_DASHED
|
||||
}
|
||||
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points, strokeStyle)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
@@ -630,50 +664,6 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Build points for a line stroke. For plain/dashed, just two endpoints.
|
||||
* Arrow heads are encoded as extra path segments.
|
||||
*/
|
||||
private fun buildLinePoints(
|
||||
x1: Float, y1: Float, x2: Float, y2: Float, style: LineStyle,
|
||||
): FloatArray {
|
||||
val points = mutableListOf<Float>()
|
||||
points.add(x1); points.add(y1)
|
||||
points.add(x2); points.add(y2)
|
||||
|
||||
val arrowLen = 40f // Arrow head length in canonical points
|
||||
val arrowAngle = Math.toRadians(25.0)
|
||||
val dx = (x2 - x1).toDouble()
|
||||
val dy = (y2 - y1).toDouble()
|
||||
val angle = Math.atan2(dy, dx)
|
||||
|
||||
if (style == LineStyle.ARROW || style == LineStyle.DOUBLE_ARROW) {
|
||||
// Arrow at end (x2, y2)
|
||||
val ax1 = x2 - (arrowLen * Math.cos(angle - arrowAngle)).toFloat()
|
||||
val ay1 = y2 - (arrowLen * Math.sin(angle - arrowAngle)).toFloat()
|
||||
val ax2 = x2 - (arrowLen * Math.cos(angle + arrowAngle)).toFloat()
|
||||
val ay2 = y2 - (arrowLen * Math.sin(angle + arrowAngle)).toFloat()
|
||||
// Draw as: tip -> left wing, then move back to tip -> right wing
|
||||
points.add(ax1); points.add(ay1)
|
||||
points.add(x2); points.add(y2)
|
||||
points.add(ax2); points.add(ay2)
|
||||
}
|
||||
if (style == LineStyle.DOUBLE_ARROW) {
|
||||
// Arrow at start (x1, y1)
|
||||
val revAngle = angle + Math.PI
|
||||
val bx1 = x1 - (arrowLen * Math.cos(revAngle - arrowAngle)).toFloat()
|
||||
val by1 = y1 - (arrowLen * Math.sin(revAngle - arrowAngle)).toFloat()
|
||||
val bx2 = x1 - (arrowLen * Math.cos(revAngle + arrowAngle)).toFloat()
|
||||
val by2 = y1 - (arrowLen * Math.sin(revAngle + arrowAngle)).toFloat()
|
||||
points.add(x1); points.add(y1)
|
||||
points.add(bx1); points.add(by1)
|
||||
points.add(x1); points.add(y1)
|
||||
points.add(bx2); points.add(by2)
|
||||
}
|
||||
|
||||
return points.toFloatArray()
|
||||
}
|
||||
|
||||
private fun drawArrowHeads(
|
||||
canvas: Canvas, x1: Float, y1: Float, x2: Float, y2: Float,
|
||||
style: LineStyle, strokeWidth: Float,
|
||||
@@ -996,18 +986,27 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
return path
|
||||
}
|
||||
|
||||
private fun buildPaint(penSize: Float, color: Int): Paint {
|
||||
private fun buildPaint(penSize: Float, color: Int, style: String = Stroke.STYLE_PLAIN): Paint {
|
||||
return Paint().apply {
|
||||
this.color = color
|
||||
strokeWidth = penSize
|
||||
style = Paint.Style.STROKE
|
||||
this.style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
isAntiAlias = false // Crisp lines on e-ink — no edge fading
|
||||
isAntiAlias = false
|
||||
if (style == Stroke.STYLE_DASHED) {
|
||||
pathEffect = android.graphics.DashPathEffect(floatArrayOf(30f, 20f), 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class StrokeRender(val path: Path, val paint: Paint, val id: Long)
|
||||
private data class StrokeRender(
|
||||
val path: Path,
|
||||
val paint: Paint,
|
||||
val id: Long,
|
||||
val style: String = Stroke.STYLE_PLAIN,
|
||||
val points: FloatArray? = null, // Needed for arrow rendering
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Eraser hit radius in canonical points (~3.5mm at 300 DPI). */
|
||||
|
||||
Reference in New Issue
Block a user