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 {
|
enum class Tool {
|
||||||
PEN_FINE, // 0.38mm = 4.49pt at 300 DPI
|
PEN_FINE, // 0.38mm = 4.49pt at 300 DPI
|
||||||
PEN_MEDIUM, // 0.50mm = 5.91pt at 300 DPI
|
PEN_MEDIUM, // 0.50mm = 5.91pt at 300 DPI
|
||||||
ERASER,
|
|
||||||
SELECT,
|
|
||||||
BOX, // Draw rectangles
|
|
||||||
LINE, // Draw straight lines (with style variants)
|
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 {
|
enum class LineStyle {
|
||||||
@@ -32,6 +33,7 @@ data class CanvasState(
|
|||||||
Tool.PEN_MEDIUM -> 5.91f
|
Tool.PEN_MEDIUM -> 5.91f
|
||||||
Tool.BOX -> 4.49f
|
Tool.BOX -> 4.49f
|
||||||
Tool.LINE -> 4.49f
|
Tool.LINE -> 4.49f
|
||||||
|
Tool.MOVE -> 0f
|
||||||
else -> 0f
|
else -> 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ fun EditorScreen(
|
|||||||
canvasView.onSelectionMoved = { ids, dx, dy ->
|
canvasView.onSelectionMoved = { ids, dx, dy ->
|
||||||
viewModel.moveSelection(dx, dy)
|
viewModel.moveSelection(dx, dy)
|
||||||
}
|
}
|
||||||
|
canvasView.onStrokeMoved = { id, dx, dy ->
|
||||||
|
viewModel.moveSingleStroke(id, dx, dy)
|
||||||
|
}
|
||||||
canvasView.onEdgeSwipe = { direction ->
|
canvasView.onEdgeSwipe = { direction ->
|
||||||
when (direction) {
|
when (direction) {
|
||||||
PadCanvasView.SwipeDirection.LEFT -> viewModel.navigateNext()
|
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) {
|
fun moveSelection(deltaX: Float, deltaY: Float) {
|
||||||
val toMove = _strokes.value.filter { it.id in selectedIds }
|
val toMove = _strokes.value.filter { it.id in selectedIds }
|
||||||
if (toMove.isEmpty()) return
|
if (toMove.isEmpty()) return
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
// --- Line snap ---
|
// --- Line snap ---
|
||||||
private var strokeOriginX = 0f
|
private var strokeOriginX = 0f
|
||||||
private var strokeOriginY = 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 lastStylusY = 0f
|
||||||
|
private var snapCheckX = 0f // Position when snap timer was started
|
||||||
|
private var snapCheckY = 0f
|
||||||
private var isSnappedToLine = false
|
private var isSnappedToLine = false
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private val snapRunnable = Runnable { trySnapToLine() }
|
private val snapRunnable = Runnable { trySnapToLine() }
|
||||||
@@ -88,6 +90,14 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
|
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
|
||||||
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
|
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
|
||||||
var onEdgeSwipe: ((SwipeDirection) -> 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 }
|
enum class SwipeDirection { LEFT, RIGHT }
|
||||||
|
|
||||||
@@ -343,8 +353,16 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
@Suppress("ClickableViewAccessibility")
|
@Suppress("ClickableViewAccessibility")
|
||||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
return when (event.getToolType(0)) {
|
return when (event.getToolType(0)) {
|
||||||
MotionEvent.TOOL_TYPE_STYLUS -> handleStylusInput(event)
|
MotionEvent.TOOL_TYPE_STYLUS -> {
|
||||||
MotionEvent.TOOL_TYPE_ERASER -> handleEraserInput(event) // EMR pen eraser button
|
// 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)
|
MotionEvent.TOOL_TYPE_FINGER -> handleFingerInput(event)
|
||||||
else -> super.onTouchEvent(event)
|
else -> super.onTouchEvent(event)
|
||||||
}
|
}
|
||||||
@@ -368,6 +386,9 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
if (canvasState.tool == Tool.LINE) {
|
if (canvasState.tool == Tool.LINE) {
|
||||||
return handleLineInput(event)
|
return handleLineInput(event)
|
||||||
}
|
}
|
||||||
|
if (canvasState.tool == Tool.MOVE) {
|
||||||
|
return handleMoveInput(event)
|
||||||
|
}
|
||||||
|
|
||||||
when (event.actionMasked) {
|
when (event.actionMasked) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
@@ -376,24 +397,40 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
strokeOriginY = pt[1]
|
strokeOriginY = pt[1]
|
||||||
lastStylusX = pt[0]
|
lastStylusX = pt[0]
|
||||||
lastStylusY = pt[1]
|
lastStylusY = pt[1]
|
||||||
|
snapCheckX = pt[0]
|
||||||
|
snapCheckY = pt[1]
|
||||||
isSnappedToLine = false
|
isSnappedToLine = false
|
||||||
currentPoints.clear()
|
currentPoints.clear()
|
||||||
currentPoints.add(pt[0])
|
currentPoints.add(pt[0])
|
||||||
currentPoints.add(pt[1])
|
currentPoints.add(pt[1])
|
||||||
currentPath = Path().apply { moveTo(pt[0], pt[1]) }
|
currentPath = Path().apply { moveTo(pt[0], pt[1]) }
|
||||||
currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK)
|
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)
|
handler.postDelayed(snapRunnable, LINE_SNAP_DELAY_MS)
|
||||||
invalidate()
|
invalidate()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
val path = currentPath ?: return true
|
val path = currentPath ?: return true
|
||||||
// Track current position for snap check
|
|
||||||
val curPt = screenToCanonical(event.x, event.y)
|
val curPt = screenToCanonical(event.x, event.y)
|
||||||
lastStylusX = curPt[0]
|
lastStylusX = curPt[0]
|
||||||
lastStylusY = curPt[1]
|
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) {
|
if (isSnappedToLine) {
|
||||||
// In snap mode: draw straight line from origin to current point
|
// In snap mode: draw straight line from origin to current point
|
||||||
val pt = screenToCanonical(event.x, event.y)
|
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 —
|
* Called by the snap timer. Activates line snap if the pen hasn't moved
|
||||||
* if so, activate line snap. If pen has moved far, do nothing.
|
* 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() {
|
private fun trySnapToLine() {
|
||||||
if (currentPath == null) return
|
if (currentPath == null) return
|
||||||
val dx = lastStylusX - strokeOriginX
|
val dx = lastStylusX - snapCheckX
|
||||||
val dy = lastStylusY - strokeOriginY
|
val dy = lastStylusY - snapCheckY
|
||||||
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
|
val dist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
|
||||||
if (dist <= LINE_SNAP_MOVE_THRESHOLD) {
|
if (dist <= LINE_SNAP_STILL_THRESHOLD) {
|
||||||
isSnappedToLine = true
|
isSnappedToLine = true
|
||||||
currentPath?.reset()
|
currentPath?.reset()
|
||||||
currentPath?.moveTo(strokeOriginX, strokeOriginY)
|
currentPath?.moveTo(strokeOriginX, strokeOriginY)
|
||||||
|
currentPath?.lineTo(lastStylusX, lastStylusY)
|
||||||
currentPoints.clear()
|
currentPoints.clear()
|
||||||
currentPoints.add(strokeOriginX)
|
currentPoints.add(strokeOriginX)
|
||||||
currentPoints.add(strokeOriginY)
|
currentPoints.add(strokeOriginY)
|
||||||
|
currentPoints.add(lastStylusX)
|
||||||
|
currentPoints.add(lastStylusY)
|
||||||
invalidate()
|
invalidate()
|
||||||
}
|
}
|
||||||
// If pen has moved far, don't snap — just let freehand continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Box drawing ---
|
// --- Box drawing ---
|
||||||
@@ -505,6 +545,55 @@ class PadCanvasView(context: Context) : View(context) {
|
|||||||
return true
|
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 ---
|
// --- Line drawing ---
|
||||||
|
|
||||||
private fun handleLineInput(event: MotionEvent): Boolean {
|
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. */
|
/** Hold pen still for this long to snap to straight line. */
|
||||||
private const val LINE_SNAP_DELAY_MS = 1500L
|
private const val LINE_SNAP_DELAY_MS = 1500L
|
||||||
|
|
||||||
/** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
|
/** Max movement (canonical pts) while pen is "still" for snap (~3mm). */
|
||||||
private const val LINE_SNAP_MOVE_THRESHOLD = 60f
|
private const val LINE_SNAP_STILL_THRESHOLD = 36f
|
||||||
|
|
||||||
/** Fraction of screen width that counts as the edge zone for swipes. */
|
/** Fraction of screen width that counts as the edge zone for swipes. */
|
||||||
private const val EDGE_ZONE_FRACTION = 0.08f
|
private const val EDGE_ZONE_FRACTION = 0.08f
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ fun EditorToolbar(
|
|||||||
LineStyle.DASHED -> "- -"
|
LineStyle.DASHED -> "- -"
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onToolSelected(Tool.LINE) },
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
color = if (currentTool == Tool.LINE) Color.Black else Color.White,
|
color = if (currentTool == Tool.LINE) Color.Black else Color.White,
|
||||||
contentColor = if (currentTool == Tool.LINE) Color.White else Color.Black,
|
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("Box", currentTool == Tool.BOX, { onToolSelected(Tool.BOX) })
|
||||||
ToolButton("Eraser", currentTool == Tool.ERASER, { onToolSelected(Tool.ERASER) })
|
ToolButton("Eraser", currentTool == Tool.ERASER, { onToolSelected(Tool.ERASER) })
|
||||||
ToolButton("Select", currentTool == Tool.SELECT, { onToolSelected(Tool.SELECT) })
|
ToolButton("Select", currentTool == Tool.SELECT, { onToolSelected(Tool.SELECT) })
|
||||||
|
ToolButton("Move", currentTool == Tool.MOVE, { onToolSelected(Tool.MOVE) })
|
||||||
|
|
||||||
// Selection operations: cut / del / copy / paste
|
// Selection operations: cut / del / copy / paste
|
||||||
if (hasSelection || canPaste) {
|
if (hasSelection || canPaste) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -29,6 +30,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||||
@@ -173,6 +175,9 @@ private fun CreateNotebookDialog(
|
|||||||
onValueChange = { title = it },
|
onValueChange = { title = it },
|
||||||
label = { Text("Title") },
|
label = { Text("Title") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Words,
|
||||||
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user