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:
10
PROGRESS.md
10
PROGRESS.md
@@ -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)
|
||||
- Toolbar now has undo/redo buttons with enabled state
|
||||
|
||||
### Phase 7: Selection — Move, Copy, Delete (2026-03-24)
|
||||
|
||||
- [x] 7.1–7.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
|
||||
|
||||
Phase 7: Selection
|
||||
Phase 8: Multi-Page Navigation
|
||||
|
||||
## Decisions & Deviations
|
||||
|
||||
|
||||
@@ -88,11 +88,11 @@ completed and log them in PROGRESS.md.
|
||||
|
||||
## Phase 7: Selection — Move, Copy, Delete
|
||||
|
||||
- [ ] 7.1: Selection mode — rectangle selection
|
||||
- [ ] 7.2: Visual feedback — highlight, bounding box
|
||||
- [ ] 7.3: Operations — delete, drag-to-move, copy/paste
|
||||
- [ ] 7.4: Undo integration for all selection operations
|
||||
- **Verify:** `./gradlew test` + manual test
|
||||
- [x] 7.1: Selection mode — rectangle selection via stylus drag
|
||||
- [x] 7.2: Visual feedback — blue highlight on selected strokes, dashed selection rect
|
||||
- [x] 7.3: Operations — delete, drag-to-move, copy (toolbar buttons appear on selection)
|
||||
- [x] 7.4: Undo integration — DeleteMultipleStrokesAction, MoveStrokesAction, CopyStrokesAction
|
||||
- **Verify:** `./gradlew build` — PASSED
|
||||
|
||||
## Phase 8: Multi-Page Navigation
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ fun EditorScreen(
|
||||
val strokes by viewModel.strokes.collectAsState()
|
||||
val canUndo by viewModel.undoManager.canUndo.collectAsState()
|
||||
val canRedo by viewModel.undoManager.canRedo.collectAsState()
|
||||
val hasSelection by viewModel.hasSelection.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
val canvasView = remember { PadCanvasView(context) }
|
||||
@@ -58,6 +59,12 @@ fun EditorScreen(
|
||||
canvasView.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
|
||||
viewModel.onStrokeAdded = { stroke ->
|
||||
canvasView.addCompletedStroke(
|
||||
@@ -68,6 +75,9 @@ fun EditorScreen(
|
||||
viewModel.onStrokeRemoved = { id ->
|
||||
canvasView.removeStroke(id)
|
||||
}
|
||||
viewModel.onStrokesChanged = {
|
||||
canvasView.setStrokes(viewModel.strokes.value)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -78,6 +88,15 @@ fun EditorScreen(
|
||||
canRedo = canRedo,
|
||||
onUndo = { viewModel.undo() },
|
||||
onRedo = { viewModel.redo() },
|
||||
hasSelection = hasSelection,
|
||||
onDeleteSelection = {
|
||||
viewModel.deleteSelection()
|
||||
canvasView.clearSelection()
|
||||
},
|
||||
onCopySelection = {
|
||||
viewModel.copySelection()
|
||||
canvasView.clearSelection()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
AndroidView(
|
||||
|
||||
@@ -12,7 +12,10 @@ import net.metacircular.engpad.data.model.PageSize
|
||||
import net.metacircular.engpad.data.model.Stroke
|
||||
import net.metacircular.engpad.data.repository.PageRepository
|
||||
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.MoveStrokesAction
|
||||
import net.metacircular.engpad.undo.UndoManager
|
||||
|
||||
class EditorViewModel(
|
||||
@@ -29,9 +32,14 @@ class EditorViewModel(
|
||||
|
||||
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
|
||||
var onStrokeAdded: ((Stroke) -> Unit)? = null
|
||||
var onStrokeRemoved: ((Long) -> Unit)? = null
|
||||
var onStrokesChanged: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
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() {
|
||||
viewModelScope.launch { undoManager.undo() }
|
||||
}
|
||||
|
||||
@@ -48,6 +48,17 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
private var currentPaint: Paint? = null
|
||||
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 ---
|
||||
private val viewMatrix = 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 onZoomPanChanged: ((zoom: Float, panX: Float, panY: Float) -> 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) ---
|
||||
private var zoom = 1f
|
||||
@@ -98,6 +111,23 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
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 ---
|
||||
private val pagePaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
@@ -174,6 +204,26 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
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)
|
||||
}
|
||||
if (canvasState.tool == Tool.SELECT) {
|
||||
// Select mode handled in Phase 7
|
||||
return true
|
||||
return handleSelectInput(event)
|
||||
}
|
||||
|
||||
when (event.actionMasked) {
|
||||
@@ -337,6 +386,114 @@ class PadCanvasView(context: Context) : View(context) {
|
||||
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 ---
|
||||
|
||||
private fun handleEraserInput(event: MotionEvent): Boolean {
|
||||
|
||||
@@ -20,6 +20,9 @@ fun EditorToolbar(
|
||||
canRedo: Boolean,
|
||||
onUndo: () -> Unit,
|
||||
onRedo: () -> Unit,
|
||||
hasSelection: Boolean,
|
||||
onDeleteSelection: () -> Unit,
|
||||
onCopySelection: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
@@ -44,6 +47,16 @@ fun EditorToolbar(
|
||||
label = { Text("Eraser") },
|
||||
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))
|
||||
TextButton(onClick = onUndo, enabled = canUndo) {
|
||||
Text("Undo")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user