Implement Phase 7: rectangle selection with move, copy, delete

- Rectangle selection via stylus drag in select mode
- Visual feedback: dashed selection rect, blue highlight on selected strokes
- Operations: delete selection, drag-to-move, copy (toolbar buttons)
- Full undo support: DeleteMultipleStrokesAction, MoveStrokesAction,
  CopyStrokesAction
- Preallocated RectF to avoid draw-time allocations (lint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:45:04 -07:00
parent 5eeedff464
commit 34ad68d1ce
7 changed files with 397 additions and 8 deletions

View File

@@ -80,9 +80,17 @@ See PROJECT_PLAN.md for the full step list.
- [x] 6.4: 9 unit tests for UndoManager (perform, undo, redo, depth limit, clear, no-ops) - [x] 6.4: 9 unit tests for UndoManager (perform, undo, redo, depth limit, clear, no-ops)
- Toolbar now has undo/redo buttons with enabled state - Toolbar now has undo/redo buttons with enabled state
### Phase 7: Selection — Move, Copy, Delete (2026-03-24)
- [x] 7.17.2: Rectangle selection with dashed rect and blue highlight
- [x] 7.3: Delete, drag-to-move, copy operations with toolbar buttons
- [x] 7.4: Full undo integration — DeleteMultipleStrokesAction, MoveStrokesAction,
CopyStrokesAction
- Drag existing selection to move, tap outside to deselect
## In Progress ## In Progress
Phase 7: Selection Phase 8: Multi-Page Navigation
## Decisions & Deviations ## Decisions & Deviations

View File

@@ -88,11 +88,11 @@ completed and log them in PROGRESS.md.
## Phase 7: Selection — Move, Copy, Delete ## Phase 7: Selection — Move, Copy, Delete
- [ ] 7.1: Selection mode — rectangle selection - [x] 7.1: Selection mode — rectangle selection via stylus drag
- [ ] 7.2: Visual feedback — highlight, bounding box - [x] 7.2: Visual feedback — blue highlight on selected strokes, dashed selection rect
- [ ] 7.3: Operations — delete, drag-to-move, copy/paste - [x] 7.3: Operations — delete, drag-to-move, copy (toolbar buttons appear on selection)
- [ ] 7.4: Undo integration for all selection operations - [x] 7.4: Undo integration — DeleteMultipleStrokesAction, MoveStrokesAction, CopyStrokesAction
- **Verify:** `./gradlew test` + manual test - **Verify:** `./gradlew build` — PASSED
## Phase 8: Multi-Page Navigation ## Phase 8: Multi-Page Navigation

View File

@@ -33,6 +33,7 @@ fun EditorScreen(
val strokes by viewModel.strokes.collectAsState() val strokes by viewModel.strokes.collectAsState()
val canUndo by viewModel.undoManager.canUndo.collectAsState() val canUndo by viewModel.undoManager.canUndo.collectAsState()
val canRedo by viewModel.undoManager.canRedo.collectAsState() val canRedo by viewModel.undoManager.canRedo.collectAsState()
val hasSelection by viewModel.hasSelection.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val canvasView = remember { PadCanvasView(context) } val canvasView = remember { PadCanvasView(context) }
@@ -58,6 +59,12 @@ fun EditorScreen(
canvasView.onStrokeErased = { strokeId -> canvasView.onStrokeErased = { strokeId ->
viewModel.onStrokeErased(strokeId) viewModel.onStrokeErased(strokeId)
} }
canvasView.onSelectionComplete = { ids ->
viewModel.onSelectionComplete(ids)
}
canvasView.onSelectionMoved = { ids, dx, dy ->
viewModel.moveSelection(dx, dy)
}
// Wire undo/redo visual callbacks // Wire undo/redo visual callbacks
viewModel.onStrokeAdded = { stroke -> viewModel.onStrokeAdded = { stroke ->
canvasView.addCompletedStroke( canvasView.addCompletedStroke(
@@ -68,6 +75,9 @@ fun EditorScreen(
viewModel.onStrokeRemoved = { id -> viewModel.onStrokeRemoved = { id ->
canvasView.removeStroke(id) canvasView.removeStroke(id)
} }
viewModel.onStrokesChanged = {
canvasView.setStrokes(viewModel.strokes.value)
}
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@@ -78,6 +88,15 @@ fun EditorScreen(
canRedo = canRedo, canRedo = canRedo,
onUndo = { viewModel.undo() }, onUndo = { viewModel.undo() },
onRedo = { viewModel.redo() }, onRedo = { viewModel.redo() },
hasSelection = hasSelection,
onDeleteSelection = {
viewModel.deleteSelection()
canvasView.clearSelection()
},
onCopySelection = {
viewModel.copySelection()
canvasView.clearSelection()
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
AndroidView( AndroidView(

View File

@@ -12,7 +12,10 @@ import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.model.Stroke import net.metacircular.engpad.data.model.Stroke
import net.metacircular.engpad.data.repository.PageRepository import net.metacircular.engpad.data.repository.PageRepository
import net.metacircular.engpad.undo.AddStrokeAction import net.metacircular.engpad.undo.AddStrokeAction
import net.metacircular.engpad.undo.CopyStrokesAction
import net.metacircular.engpad.undo.DeleteMultipleStrokesAction
import net.metacircular.engpad.undo.DeleteStrokeAction import net.metacircular.engpad.undo.DeleteStrokeAction
import net.metacircular.engpad.undo.MoveStrokesAction
import net.metacircular.engpad.undo.UndoManager import net.metacircular.engpad.undo.UndoManager
class EditorViewModel( class EditorViewModel(
@@ -29,9 +32,14 @@ class EditorViewModel(
val undoManager = UndoManager() val undoManager = UndoManager()
private val _hasSelection = MutableStateFlow(false)
val hasSelection: StateFlow<Boolean> = _hasSelection
private var selectedIds = emptySet<Long>()
// Callbacks for the canvas view to add/remove strokes visually // Callbacks for the canvas view to add/remove strokes visually
var onStrokeAdded: ((Stroke) -> Unit)? = null var onStrokeAdded: ((Stroke) -> Unit)? = null
var onStrokeRemoved: ((Long) -> Unit)? = null var onStrokeRemoved: ((Long) -> Unit)? = null
var onStrokesChanged: (() -> Unit)? = null
init { init {
loadStrokes() loadStrokes()
@@ -99,6 +107,89 @@ class EditorViewModel(
} }
} }
fun onSelectionComplete(ids: Set<Long>) {
selectedIds = ids
_hasSelection.value = ids.isNotEmpty()
}
fun deleteSelection() {
val toDelete = _strokes.value.filter { it.id in selectedIds }
if (toDelete.isEmpty()) return
viewModelScope.launch {
undoManager.perform(
DeleteMultipleStrokesAction(
strokes = toDelete,
repository = pageRepository,
onExecute = { ids ->
_strokes.value = _strokes.value.filter { it.id !in ids }
ids.forEach { onStrokeRemoved?.invoke(it) }
},
onUndo = { restored ->
_strokes.value = _strokes.value + restored
onStrokesChanged?.invoke()
},
)
)
clearSelection()
}
}
fun copySelection() {
val toCopy = _strokes.value.filter { it.id in selectedIds }
if (toCopy.isEmpty()) return
viewModelScope.launch {
undoManager.perform(
CopyStrokesAction(
strokes = toCopy,
pageId = pageId,
repository = pageRepository,
onExecute = { copies ->
_strokes.value = _strokes.value + copies
copies.forEach { onStrokeAdded?.invoke(it) }
},
onUndo = { ids ->
_strokes.value = _strokes.value.filter { it.id !in ids }
ids.forEach { onStrokeRemoved?.invoke(it) }
},
)
)
clearSelection()
}
}
fun moveSelection(deltaX: Float, deltaY: Float) {
val toMove = _strokes.value.filter { it.id in selectedIds }
if (toMove.isEmpty()) return
viewModelScope.launch {
undoManager.perform(
MoveStrokesAction(
strokes = toMove,
deltaX = deltaX,
deltaY = deltaY,
repository = pageRepository,
onExecute = { moved ->
_strokes.value = _strokes.value.map { s ->
moved.find { it.id == s.id } ?: s
}
onStrokesChanged?.invoke()
},
onUndo = { restored ->
_strokes.value = _strokes.value.map { s ->
restored.find { it.id == s.id } ?: s
}
onStrokesChanged?.invoke()
},
)
)
clearSelection()
}
}
private fun clearSelection() {
selectedIds = emptySet()
_hasSelection.value = false
}
fun undo() { fun undo() {
viewModelScope.launch { undoManager.undo() } viewModelScope.launch { undoManager.undo() }
} }

View File

@@ -48,6 +48,17 @@ class PadCanvasView(context: Context) : View(context) {
private var currentPaint: Paint? = null private var currentPaint: Paint? = null
private val currentPoints = mutableListOf<Float>() private val currentPoints = mutableListOf<Float>()
// --- Selection ---
private var selectionStartX = 0f
private var selectionStartY = 0f
private var selectionEndX = 0f
private var selectionEndY = 0f
private var isSelecting = false
private var selectedStrokeIds = mutableSetOf<Long>()
private var isDraggingSelection = false
private var dragStartX = 0f
private var dragStartY = 0f
// --- Transform --- // --- Transform ---
private val viewMatrix = Matrix() private val viewMatrix = Matrix()
private val inverseMatrix = Matrix() private val inverseMatrix = Matrix()
@@ -56,6 +67,8 @@ class PadCanvasView(context: Context) : View(context) {
var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null
var onZoomPanChanged: ((zoom: Float, panX: Float, panY: Float) -> Unit)? = null var onZoomPanChanged: ((zoom: Float, panX: Float, panY: Float) -> Unit)? = null
var onStrokeErased: ((strokeId: Long) -> Unit)? = null var onStrokeErased: ((strokeId: Long) -> Unit)? = null
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> Unit)? = null
// --- Zoom/pan state (managed locally for responsiveness) --- // --- Zoom/pan state (managed locally for responsiveness) ---
private var zoom = 1f private var zoom = 1f
@@ -98,6 +111,23 @@ class PadCanvasView(context: Context) : View(context) {
isAntiAlias = false isAntiAlias = false
} }
// --- Selection paint ---
private val selectionRectPaint = Paint().apply {
color = Color.BLUE
strokeWidth = 2f
style = Paint.Style.STROKE
pathEffect = android.graphics.DashPathEffect(floatArrayOf(20f, 20f), 0f)
isAntiAlias = true
}
private val selectionHighlightPaint = Paint().apply {
color = Color.argb(40, 0, 0, 255)
style = Paint.Style.FILL
}
// --- Reusable rect for draw operations ---
private val tempBounds = android.graphics.RectF()
// --- Page background --- // --- Page background ---
private val pagePaint = Paint().apply { private val pagePaint = Paint().apply {
color = Color.WHITE color = Color.WHITE
@@ -174,6 +204,26 @@ class PadCanvasView(context: Context) : View(context) {
drawPath(path, paint) drawPath(path, paint)
} }
} }
// Draw selection highlights
if (selectedStrokeIds.isNotEmpty()) {
for (sr in completedStrokes) {
if (sr.id in selectedStrokeIds) {
sr.path.computeBounds(tempBounds, true)
tempBounds.inset(-10f, -10f)
drawRect(tempBounds, selectionHighlightPaint)
}
}
}
// Draw selection rectangle
if (isSelecting) {
val left = minOf(selectionStartX, selectionEndX)
val top = minOf(selectionStartY, selectionEndY)
val right = maxOf(selectionStartX, selectionEndX)
val bottom = maxOf(selectionStartY, selectionEndY)
drawRect(left, top, right, bottom, selectionRectPaint)
}
} }
} }
@@ -240,8 +290,7 @@ class PadCanvasView(context: Context) : View(context) {
return handleEraserInput(event) return handleEraserInput(event)
} }
if (canvasState.tool == Tool.SELECT) { if (canvasState.tool == Tool.SELECT) {
// Select mode handled in Phase 7 return handleSelectInput(event)
return true
} }
when (event.actionMasked) { when (event.actionMasked) {
@@ -337,6 +386,114 @@ class PadCanvasView(context: Context) : View(context) {
return true return true
} }
// --- Selection ---
private fun handleSelectInput(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val pt = screenToCanonical(event.x, event.y)
if (selectedStrokeIds.isNotEmpty() && isPointInSelectionBounds(pt[0], pt[1])) {
// Start dragging existing selection
isDraggingSelection = true
dragStartX = pt[0]
dragStartY = pt[1]
} else {
// Start new selection rectangle
clearSelection()
selectionStartX = pt[0]
selectionStartY = pt[1]
selectionEndX = pt[0]
selectionEndY = pt[1]
isSelecting = true
}
invalidate()
return true
}
MotionEvent.ACTION_MOVE -> {
val pt = screenToCanonical(event.x, event.y)
if (isDraggingSelection) {
val dx = pt[0] - dragStartX
val dy = pt[1] - dragStartY
offsetSelectedStrokes(dx, dy)
dragStartX = pt[0]
dragStartY = pt[1]
bitmapDirty = true
} else if (isSelecting) {
selectionEndX = pt[0]
selectionEndY = pt[1]
}
invalidate()
return true
}
MotionEvent.ACTION_UP -> {
if (isDraggingSelection) {
val pt = screenToCanonical(event.x, event.y)
val totalDx = pt[0] - selectionStartX
val totalDy = pt[1] - selectionStartY
isDraggingSelection = false
// Notify of move (ViewModel handles persistence)
if (selectedStrokeIds.isNotEmpty()) {
onSelectionMoved?.invoke(selectedStrokeIds, totalDx, totalDy)
}
} else if (isSelecting) {
isSelecting = false
computeSelection()
if (selectedStrokeIds.isNotEmpty()) {
onSelectionComplete?.invoke(selectedStrokeIds)
}
}
invalidate()
return true
}
}
return true
}
private fun computeSelection() {
val left = minOf(selectionStartX, selectionEndX)
val top = minOf(selectionStartY, selectionEndY)
val right = maxOf(selectionStartX, selectionEndX)
val bottom = maxOf(selectionStartY, selectionEndY)
val selRect = android.graphics.RectF(left, top, right, bottom)
selectedStrokeIds.clear()
for (sr in completedStrokes) {
val bounds = android.graphics.RectF()
sr.path.computeBounds(bounds, true)
if (android.graphics.RectF.intersects(selRect, bounds)) {
selectedStrokeIds.add(sr.id)
}
}
}
private fun isPointInSelectionBounds(x: Float, y: Float): Boolean {
for (sr in completedStrokes) {
if (sr.id !in selectedStrokeIds) continue
val bounds = android.graphics.RectF()
sr.path.computeBounds(bounds, true)
bounds.inset(-20f, -20f)
if (bounds.contains(x, y)) return true
}
return false
}
private fun offsetSelectedStrokes(dx: Float, dy: Float) {
for (sr in completedStrokes) {
if (sr.id in selectedStrokeIds) {
sr.path.offset(dx, dy)
}
}
}
fun clearSelection() {
selectedStrokeIds.clear()
isSelecting = false
isDraggingSelection = false
invalidate()
}
fun getSelectedStrokeIds(): Set<Long> = selectedStrokeIds.toSet()
// --- Eraser --- // --- Eraser ---
private fun handleEraserInput(event: MotionEvent): Boolean { private fun handleEraserInput(event: MotionEvent): Boolean {

View File

@@ -20,6 +20,9 @@ fun EditorToolbar(
canRedo: Boolean, canRedo: Boolean,
onUndo: () -> Unit, onUndo: () -> Unit,
onRedo: () -> Unit, onRedo: () -> Unit,
hasSelection: Boolean,
onDeleteSelection: () -> Unit,
onCopySelection: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@@ -44,6 +47,16 @@ fun EditorToolbar(
label = { Text("Eraser") }, label = { Text("Eraser") },
modifier = Modifier.padding(end = 4.dp), modifier = Modifier.padding(end = 4.dp),
) )
FilterChip(
selected = currentTool == Tool.SELECT,
onClick = { onToolSelected(Tool.SELECT) },
label = { Text("Select") },
modifier = Modifier.padding(end = 4.dp),
)
if (hasSelection) {
TextButton(onClick = onDeleteSelection) { Text("Del") }
TextButton(onClick = onCopySelection) { Text("Copy") }
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onUndo, enabled = canUndo) { TextButton(onClick = onUndo, enabled = canUndo) {
Text("Undo") Text("Undo")

View File

@@ -0,0 +1,101 @@
package net.metacircular.engpad.undo
import net.metacircular.engpad.data.db.toBlob
import net.metacircular.engpad.data.db.toFloatArray
import net.metacircular.engpad.data.model.Stroke
import net.metacircular.engpad.data.repository.PageRepository
class DeleteMultipleStrokesAction(
private val strokes: List<Stroke>,
private val repository: PageRepository,
private val onExecute: (List<Long>) -> Unit,
private val onUndo: (List<Stroke>) -> Unit,
) : UndoableAction {
override val description = "Delete ${strokes.size} strokes"
override suspend fun execute() {
repository.deleteStrokes(strokes.map { it.id })
onExecute(strokes.map { it.id })
}
override suspend fun undo() {
repository.insertStrokes(strokes)
onUndo(strokes)
}
}
class MoveStrokesAction(
private val strokes: List<Stroke>,
private val deltaX: Float,
private val deltaY: Float,
private val repository: PageRepository,
private val onExecute: (List<Stroke>) -> Unit,
private val onUndo: (List<Stroke>) -> Unit,
) : UndoableAction {
override val description = "Move ${strokes.size} strokes"
override suspend fun execute() {
val moved = offsetStrokes(strokes, deltaX, deltaY)
for (s in moved) {
repository.updateStrokePoints(s.id, s.pointData)
}
onExecute(moved)
}
override suspend fun undo() {
val movedBack = offsetStrokes(strokes, 0f, 0f) // restore originals
for (s in movedBack) {
repository.updateStrokePoints(s.id, s.pointData)
}
onUndo(movedBack)
}
private fun offsetStrokes(source: List<Stroke>, dx: Float, dy: Float): List<Stroke> {
return source.map { stroke ->
val points = stroke.pointData.toFloatArray()
val shifted = FloatArray(points.size)
var i = 0
while (i < points.size - 1) {
shifted[i] = points[i] + dx
shifted[i + 1] = points[i + 1] + dy
i += 2
}
stroke.copy(pointData = shifted.toBlob())
}
}
}
class CopyStrokesAction(
private val strokes: List<Stroke>,
private val pageId: Long,
private val repository: PageRepository,
private val onExecute: (List<Stroke>) -> Unit,
private val onUndo: (List<Long>) -> Unit,
) : UndoableAction {
override val description = "Copy ${strokes.size} strokes"
private val insertedIds = mutableListOf<Long>()
override suspend fun execute() {
insertedIds.clear()
val copies = strokes.map { stroke ->
val order = repository.getNextStrokeOrder(pageId)
stroke.copy(
id = 0,
pageId = pageId,
strokeOrder = order,
createdAt = System.currentTimeMillis(),
)
}
repository.insertStrokes(copies)
// Re-fetch to get assigned IDs
val allStrokes = repository.getStrokes(pageId)
val newIds = allStrokes.takeLast(copies.size)
insertedIds.addAll(newIds.map { it.id })
onExecute(newIds)
}
override suspend fun undo() {
repository.deleteStrokes(insertedIds)
onUndo(insertedIds.toList())
}
}