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 = {
canvasView.setStrokes(viewModel.strokes.value)
}
viewModel.onSelectionChanged = { ids ->
canvasView.setSelection(ids)
}
}
Column(modifier = Modifier.fillMaxSize()) {

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
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.PageSize
import net.metacircular.engpad.data.model.Stroke
@@ -43,6 +44,10 @@ class EditorViewModel(
private val _canPaste = MutableStateFlow(false)
val canPaste: StateFlow<Boolean> = _canPaste
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
private val _pages = MutableStateFlow<List<Page>>(emptyList())
@@ -211,35 +216,62 @@ class EditorViewModel(
fun cutSelection() {
clipboard = _strokes.value.filter { it.id in selectedIds }
clipboardIsCut = true
_canPaste.value = clipboard.isNotEmpty()
deleteSelection()
}
fun copyToClipboard() {
clipboard = _strokes.value.filter { it.id in selectedIds }
clipboardIsCut = false
_canPaste.value = clipboard.isNotEmpty()
clearSelection()
}
fun paste() {
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 {
val pageId = _currentPageId.value
undoManager.perform(
CopyStrokesAction(
strokes = clipboard,
strokes = offsetStrokes,
pageId = pageId,
repository = pageRepository,
onExecute = { 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 ->
_strokes.value = _strokes.value.filter { it.id !in ids }
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() }
}
companion object {
/** Offset for copy+paste in canonical points (~10mm diagonal). */
private const val PASTE_OFFSET_PT = 120f
}
class Factory(
private val notebookId: Long,
private val pageSize: PageSize,

View File

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