Fix UI polish: snap, line dropdown, move tool, eraser button, title case
1. Line long-press dropdown: removed conflicting onClick from Surface, combinedClickable now works correctly for long-press style menu 2. Move tool: tap a stroke and drag to reposition it, with undo support and visual highlight during drag 3. Snap fix: timer now re-arms continuously — pausing at ANY point during a stroke (not just near origin) triggers snap. Checks pen stillness at the moment the timer fires (~3mm threshold) 4. EMR eraser: now checks BUTTON_STYLUS_PRIMARY in buttonState (side button) in addition to TOOL_TYPE_ERASER 5. Notebook title: keyboard defaults to Title Case capitalization 6. Tool order in enum matches toolbar: pen, line, box, eraser, select, move Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,11 @@ import net.metacircular.engpad.data.model.PageSize
|
||||
enum class Tool {
|
||||
PEN_FINE, // 0.38mm = 4.49pt at 300 DPI
|
||||
PEN_MEDIUM, // 0.50mm = 5.91pt at 300 DPI
|
||||
ERASER,
|
||||
SELECT,
|
||||
BOX, // Draw rectangles
|
||||
LINE, // Draw straight lines (with style variants)
|
||||
BOX, // Draw rectangles
|
||||
ERASER,
|
||||
SELECT, // Rectangle select, then cut/copy/del/paste
|
||||
MOVE, // Tap and drag individual strokes
|
||||
}
|
||||
|
||||
enum class LineStyle {
|
||||
@@ -32,6 +33,7 @@ data class CanvasState(
|
||||
Tool.PEN_MEDIUM -> 5.91f
|
||||
Tool.BOX -> 4.49f
|
||||
Tool.LINE -> 4.49f
|
||||
Tool.MOVE -> 0f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ fun EditorScreen(
|
||||
canvasView.onSelectionMoved = { ids, dx, dy ->
|
||||
viewModel.moveSelection(dx, dy)
|
||||
}
|
||||
canvasView.onStrokeMoved = { id, dx, dy ->
|
||||
viewModel.moveSingleStroke(id, dx, dy)
|
||||
}
|
||||
canvasView.onEdgeSwipe = { direction ->
|
||||
when (direction) {
|
||||
PadCanvasView.SwipeDirection.LEFT -> viewModel.navigateNext()
|
||||
|
||||
@@ -266,6 +266,32 @@ class EditorViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun moveSingleStroke(strokeId: Long, deltaX: Float, deltaY: Float) {
|
||||
val stroke = _strokes.value.find { it.id == strokeId } ?: return
|
||||
viewModelScope.launch {
|
||||
undoManager.perform(
|
||||
MoveStrokesAction(
|
||||
strokes = listOf(stroke),
|
||||
deltaX = deltaX,
|
||||
deltaY = deltaY,
|
||||
repository = pageRepository,
|
||||
onExecute = { moved ->
|
||||
_strokes.value = _strokes.value.map { s ->
|
||||
moved.find { it.id == s.id } ?: s
|
||||
}
|
||||
// Don't call onStrokesChanged — the view already moved the path
|
||||
},
|
||||
onUndo = { restored ->
|
||||
_strokes.value = _strokes.value.map { s ->
|
||||
restored.find { it.id == s.id } ?: s
|
||||
}
|
||||
onStrokesChanged?.invoke()
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun moveSelection(deltaX: Float, deltaY: Float) {
|
||||
val toMove = _strokes.value.filter { it.id in selectedIds }
|
||||
if (toMove.isEmpty()) return
|
||||
|
||||
@@ -46,8 +46,10 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
// --- Line snap ---
|
||||
private var strokeOriginX = 0f
|
||||
private var strokeOriginY = 0f
|
||||
private var lastStylusX = 0f // Current pen position for snap check
|
||||
private var lastStylusX = 0f
|
||||
private var lastStylusY = 0f
|
||||
private var snapCheckX = 0f // Position when snap timer was started
|
||||
private var snapCheckY = 0f
|
||||
private var isSnappedToLine = false
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val snapRunnable = Runnable { trySnapToLine() }
|
||||
@@ -88,6 +90,14 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
|
||||
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
|
||||
var onEdgeSwipe: ((SwipeDirection) -> Unit)? = null
|
||||
var onStrokeMoved: ((strokeId: Long, deltaX: Float, deltaY: Float) -> Unit)? = null
|
||||
|
||||
// --- Move tool state ---
|
||||
private var movingStrokeId: Long? = null
|
||||
private var moveLastX = 0f
|
||||
private var moveLastY = 0f
|
||||
private var moveOriginX = 0f
|
||||
private var moveOriginY = 0f
|
||||
|
||||
enum class SwipeDirection { LEFT, RIGHT }
|
||||
|
||||
@@ -343,8 +353,16 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
@Suppress("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return when (event.getToolType(0)) {
|
||||
MotionEvent.TOOL_TYPE_STYLUS -> handleStylusInput(event)
|
||||
MotionEvent.TOOL_TYPE_ERASER -> handleEraserInput(event) // EMR pen eraser button
|
||||
MotionEvent.TOOL_TYPE_STYLUS -> {
|
||||
// Check for stylus button press (side button = eraser shortcut)
|
||||
val buttonPressed = event.buttonState and MotionEvent.BUTTON_STYLUS_PRIMARY != 0
|
||||
if (buttonPressed) {
|
||||
handleEraserInput(event)
|
||||
} else {
|
||||
handleStylusInput(event)
|
||||
}
|
||||
}
|
||||
MotionEvent.TOOL_TYPE_ERASER -> handleEraserInput(event)
|
||||
MotionEvent.TOOL_TYPE_FINGER -> handleFingerInput(event)
|
||||
else -> super.onTouchEvent(event)
|
||||
}
|
||||
@@ -368,6 +386,9 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
if (canvasState.tool == Tool.LINE) {
|
||||
return handleLineInput(event)
|
||||
}
|
||||
if (canvasState.tool == Tool.MOVE) {
|
||||
return handleMoveInput(event)
|
||||
}
|
||||
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
@@ -376,24 +397,40 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
strokeOriginY = pt[1]
|
||||
lastStylusX = pt[0]
|
||||
lastStylusY = pt[1]
|
||||
snapCheckX = pt[0]
|
||||
snapCheckY = pt[1]
|
||||
isSnappedToLine = false
|
||||
currentPoints.clear()
|
||||
currentPoints.add(pt[0])
|
||||
currentPoints.add(pt[1])
|
||||
currentPath = Path().apply { moveTo(pt[0], pt[1]) }
|
||||
currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK)
|
||||
// Start snap timer — will check distance when it fires
|
||||
// Start snap timer
|
||||
handler.removeCallbacks(snapRunnable)
|
||||
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val path = currentPath ?: return true
|
||||
// Track current position for snap check
|
||||
val curPt = screenToCanonical(event.x, event.y)
|
||||
lastStylusX = curPt[0]
|
||||
lastStylusY = curPt[1]
|
||||
|
||||
// Re-arm snap timer: if pen moved significantly from last
|
||||
// snap check point, restart the timer from current position
|
||||
if (!isSnappedToLine) {
|
||||
val dx = curPt[0] - snapCheckX
|
||||
val dy = curPt[1] - snapCheckY
|
||||
val moved = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
|
||||
if (moved > LINE_SNAP_STILL_THRESHOLD) {
|
||||
snapCheckX = curPt[0]
|
||||
snapCheckY = curPt[1]
|
||||
handler.removeCallbacks(snapRunnable)
|
||||
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSnappedToLine) {
|
||||
// In snap mode: draw straight line from origin to current point
|
||||
val pt = screenToCanonical(event.x, event.y)
|
||||
@@ -442,24 +479,27 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the snap timer. Checks if pen is still near origin —
|
||||
* if so, activate line snap. If pen has moved far, do nothing.
|
||||
* Called by the snap timer. Activates line snap if the pen hasn't moved
|
||||
* much since the timer was last (re)started. The pen can be anywhere —
|
||||
* not just near origin — pausing at any point triggers snap.
|
||||
*/
|
||||
private fun trySnapToLine() {
|
||||
if (currentPath == null) return
|
||||
val dx = lastStylusX - strokeOriginX
|
||||
val dy = lastStylusY - strokeOriginY
|
||||
val dx = lastStylusX - snapCheckX
|
||||
val dy = lastStylusY - snapCheckY
|
||||
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
|
||||
if (dist <= LINE_SNAP_MOVE_THRESHOLD) {
|
||||
if (dist <= LINE_SNAP_STILL_THRESHOLD) {
|
||||
isSnappedToLine = true
|
||||
currentPath?.reset()
|
||||
currentPath?.moveTo(strokeOriginX, strokeOriginY)
|
||||
currentPath?.lineTo(lastStylusX, lastStylusY)
|
||||
currentPoints.clear()
|
||||
currentPoints.add(strokeOriginX)
|
||||
currentPoints.add(strokeOriginY)
|
||||
currentPoints.add(lastStylusX)
|
||||
currentPoints.add(lastStylusY)
|
||||
invalidate()
|
||||
}
|
||||
// If pen has moved far, don't snap — just let freehand continue
|
||||
}
|
||||
|
||||
// --- Box drawing ---
|
||||
@@ -505,6 +545,55 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
return true
|
||||
}
|
||||
|
||||
// --- Move tool ---
|
||||
|
||||
private fun handleMoveInput(event: MotionEvent): Boolean {
|
||||
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()
|
||||
}
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val id = movingStrokeId ?: 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)
|
||||
moveLastX = pt[0]
|
||||
moveLastY = pt[1]
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val id = movingStrokeId ?: return true
|
||||
val pt = screenToCanonical(event.x, event.y)
|
||||
val totalDx = pt[0] - moveOriginX
|
||||
val totalDy = pt[1] - moveOriginY
|
||||
movingStrokeId = null
|
||||
selectedStrokeIds.clear()
|
||||
if (Math.abs(totalDx) > 1f || Math.abs(totalDy) > 1f) {
|
||||
onStrokeMoved?.invoke(id, totalDx, totalDy)
|
||||
}
|
||||
invalidate()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- Line drawing ---
|
||||
|
||||
private fun handleLineInput(event: MotionEvent): Boolean {
|
||||
@@ -927,8 +1016,8 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
/** Hold pen still for this long to snap to straight line. */
|
||||
private const val LINE_SNAP_DELAY_MS = 1500L
|
||||
|
||||
/** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
|
||||
private const val LINE_SNAP_MOVE_THRESHOLD = 60f
|
||||
/** Max movement (canonical pts) while pen is "still" for snap (~3mm). */
|
||||
private const val LINE_SNAP_STILL_THRESHOLD = 36f
|
||||
|
||||
/** Fraction of screen width that counts as the edge zone for swipes. */
|
||||
private const val EDGE_ZONE_FRACTION = 0.08f
|
||||
|
||||
@@ -140,7 +140,6 @@ fun EditorToolbar(
|
||||
LineStyle.DASHED -> "- -"
|
||||
}
|
||||
Surface(
|
||||
onClick = { onToolSelected(Tool.LINE) },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (currentTool == Tool.LINE) Color.Black else Color.White,
|
||||
contentColor = if (currentTool == Tool.LINE) Color.White else Color.Black,
|
||||
@@ -184,6 +183,7 @@ fun EditorToolbar(
|
||||
ToolButton("Box", currentTool == Tool.BOX, { onToolSelected(Tool.BOX) })
|
||||
ToolButton("Eraser", currentTool == Tool.ERASER, { onToolSelected(Tool.ERASER) })
|
||||
ToolButton("Select", currentTool == Tool.SELECT, { onToolSelected(Tool.SELECT) })
|
||||
ToolButton("Move", currentTool == Tool.MOVE, { onToolSelected(Tool.MOVE) })
|
||||
|
||||
// Selection operations: cut / del / copy / paste
|
||||
if (hasSelection || canPaste) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -29,6 +30,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||
@@ -173,6 +175,9 @@ private fun CreateNotebookDialog(
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Title") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
|
||||
Reference in New Issue
Block a user