Improve paste behavior: selection retention and copy offset

Cut + paste: inserts at same position, selects the pasted strokes.
Copy + paste: offsets pasted strokes by ~10mm diagonally to distinguish
from originals, selects the new copies.

Both operations leave the pasted strokes selected so they can be
immediately moved, copied again, or deleted. After a cut+paste,
subsequent pastes behave as copy+paste (no repeated cuts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 16:44:24 -07:00
parent 1f869d556c
commit fade0de21b
3 changed files with 48 additions and 2 deletions

View File

@@ -101,6 +101,9 @@ fun EditorScreen(
viewModel.onStrokesChanged = { viewModel.onStrokesChanged = {
canvasView.setStrokes(viewModel.strokes.value) canvasView.setStrokes(viewModel.strokes.value)
} }
viewModel.onSelectionChanged = { ids ->
canvasView.setSelection(ids)
}
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.metacircular.engpad.data.db.toBlob import net.metacircular.engpad.data.db.toBlob
import net.metacircular.engpad.data.db.toFloatArray
import net.metacircular.engpad.data.model.Page import net.metacircular.engpad.data.model.Page
import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.model.Stroke import net.metacircular.engpad.data.model.Stroke
@@ -43,6 +44,10 @@ class EditorViewModel(
private val _canPaste = MutableStateFlow(false) private val _canPaste = MutableStateFlow(false)
val canPaste: StateFlow<Boolean> = _canPaste val canPaste: StateFlow<Boolean> = _canPaste
private var clipboard = emptyList<Stroke>() private var clipboard = emptyList<Stroke>()
private var clipboardIsCut = false
// Callback to update the canvas view's selection
var onSelectionChanged: ((Set<Long>) -> Unit)? = null
// Page navigation // Page navigation
private val _pages = MutableStateFlow<List<Page>>(emptyList()) private val _pages = MutableStateFlow<List<Page>>(emptyList())
@@ -211,35 +216,62 @@ class EditorViewModel(
fun cutSelection() { fun cutSelection() {
clipboard = _strokes.value.filter { it.id in selectedIds } clipboard = _strokes.value.filter { it.id in selectedIds }
clipboardIsCut = true
_canPaste.value = clipboard.isNotEmpty() _canPaste.value = clipboard.isNotEmpty()
deleteSelection() deleteSelection()
} }
fun copyToClipboard() { fun copyToClipboard() {
clipboard = _strokes.value.filter { it.id in selectedIds } clipboard = _strokes.value.filter { it.id in selectedIds }
clipboardIsCut = false
_canPaste.value = clipboard.isNotEmpty() _canPaste.value = clipboard.isNotEmpty()
clearSelection() clearSelection()
} }
fun paste() { fun paste() {
if (clipboard.isEmpty()) return if (clipboard.isEmpty()) return
val isCut = clipboardIsCut
// Offset copies so they're visually distinct from originals
val offsetStrokes = if (isCut) {
clipboard // Cut: paste at same position
} else {
clipboard.map { stroke ->
val points = stroke.pointData.toFloatArray()
val shifted = FloatArray(points.size)
var i = 0
while (i < points.size - 1) {
shifted[i] = points[i] + PASTE_OFFSET_PT
shifted[i + 1] = points[i + 1] + PASTE_OFFSET_PT
i += 2
}
stroke.copy(pointData = shifted.toBlob())
}
}
viewModelScope.launch { viewModelScope.launch {
val pageId = _currentPageId.value val pageId = _currentPageId.value
undoManager.perform( undoManager.perform(
CopyStrokesAction( CopyStrokesAction(
strokes = clipboard, strokes = offsetStrokes,
pageId = pageId, pageId = pageId,
repository = pageRepository, repository = pageRepository,
onExecute = { copies -> onExecute = { copies ->
_strokes.value = _strokes.value + copies _strokes.value = _strokes.value + copies
copies.forEach { onStrokeAdded?.invoke(it) } onStrokesChanged?.invoke()
// Select the pasted strokes
val newIds = copies.map { it.id }.toSet()
selectedIds = newIds
_hasSelection.value = true
onSelectionChanged?.invoke(newIds)
}, },
onUndo = { ids -> onUndo = { ids ->
_strokes.value = _strokes.value.filter { it.id !in ids } _strokes.value = _strokes.value.filter { it.id !in ids }
ids.forEach { onStrokeRemoved?.invoke(it) } ids.forEach { onStrokeRemoved?.invoke(it) }
clearSelection()
}, },
) )
) )
// After cut+paste, subsequent pastes should copy (not cut again)
if (isCut) clipboardIsCut = false
} }
} }
@@ -334,6 +366,11 @@ class EditorViewModel(
viewModelScope.launch { undoManager.redo() } viewModelScope.launch { undoManager.redo() }
} }
companion object {
/** Offset for copy+paste in canonical points (~10mm diagonal). */
private const val PASTE_OFFSET_PT = 120f
}
class Factory( class Factory(
private val notebookId: Long, private val notebookId: Long,
private val pageSize: PageSize, private val pageSize: PageSize,

View File

@@ -881,6 +881,12 @@ class PadCanvasView(context: Context) : View(context) {
fun getSelectedStrokeIds(): Set<Long> = selectedStrokeIds.toSet() fun getSelectedStrokeIds(): Set<Long> = selectedStrokeIds.toSet()
fun setSelection(ids: Set<Long>) {
selectedStrokeIds.clear()
selectedStrokeIds.addAll(ids)
invalidate()
}
// --- Eraser --- // --- Eraser ---
private fun handleEraserInput(event: MotionEvent): Boolean { private fun handleEraserInput(event: MotionEvent): Boolean {