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)
|
- [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.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
|
## In Progress
|
||||||
|
|
||||||
Phase 7: Selection
|
Phase 8: Multi-Page Navigation
|
||||||
|
|
||||||
## Decisions & Deviations
|
## Decisions & Deviations
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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