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:
@@ -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()) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user