diff --git a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt index b00eb98a..a5b85316 100644 --- a/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt +++ b/app/src/main/java/com/ethran/notable/data/datastore/AppSettings.kt @@ -37,6 +37,7 @@ data class AppSettings( val visualizePdfPagination: Boolean = false, val paginatePdf: Boolean = true, val scribbleToEraseEnabled: Boolean = false, + val smartLassoEnabled: Boolean = false, val simpleRendering: Boolean = false, val openGLRendering: Boolean = true, val muPdfRendering: Boolean = true, diff --git a/app/src/main/java/com/ethran/notable/data/db/Kv.kt b/app/src/main/java/com/ethran/notable/data/db/Kv.kt index b3c0cedd..b2c2a0ca 100644 --- a/app/src/main/java/com/ethran/notable/data/db/Kv.kt +++ b/app/src/main/java/com/ethran/notable/data/db/Kv.kt @@ -73,18 +73,21 @@ class KvRepository(context: Context) { class KvProxy(context: Context) { private val kvRepository = KvRepository(context) + // Configure JSON to ignore unknown keys for backward compatibility + private val json = Json { ignoreUnknownKeys = true } + fun observeKv(key: String, serializer: KSerializer, default: T): LiveData { return kvRepository.getLive(key).map { if (it == null) return@map default val jsonValue = it.value - Json.decodeFromString(serializer, jsonValue) + json.decodeFromString(serializer, jsonValue) } } fun get(key: String, serializer: KSerializer): T? { val kv = kvRepository.get(key) ?: return null //returns null when there is no database val jsonValue = kv.value - return Json.decodeFromString(serializer, jsonValue) + return json.decodeFromString(serializer, jsonValue) } diff --git a/app/src/main/java/com/ethran/notable/editor/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/editor/DrawCanvas.kt index 3656014d..f014d4ae 100644 --- a/app/src/main/java/com/ethran/notable/editor/DrawCanvas.kt +++ b/app/src/main/java/com/ethran/notable/editor/DrawCanvas.kt @@ -41,6 +41,7 @@ import com.ethran.notable.editor.utils.handleDraw import com.ethran.notable.editor.utils.handleErase import com.ethran.notable.editor.utils.handleScribbleToErase import com.ethran.notable.editor.utils.handleSelect +import com.ethran.notable.editor.utils.handleSmartLasso import com.ethran.notable.editor.utils.onSurfaceChanged import com.ethran.notable.editor.utils.onSurfaceDestroy import com.ethran.notable.editor.utils.onSurfaceInit @@ -305,6 +306,8 @@ class DrawCanvas( val scaledPoints = copyInput(plist.points, page.scroll, page.zoomLevel.value) val firstPointTime = plist.points.first().timestamp + + // First check for scribble-to-erase val erasedByScribbleDirtyRect = handleScribbleToErase( page, scaledPoints, @@ -313,17 +316,30 @@ class DrawCanvas( currentLastStrokeEndTime, firstPointTime ) + if (erasedByScribbleDirtyRect.isNullOrEmpty()) { - log.d("Drawing...") - // draw the stroke - handleDraw( + // Not a scribble-to-erase, check for smart lasso + val handledAsSmartLasso = handleSmartLasso( + coroutineScope, this@DrawCanvas.page, - strokeHistoryBatch, - getActualState().penSettings[getActualState().pen.penName]!!.strokeSize, - getActualState().penSettings[getActualState().pen.penName]!!.color, - getActualState().pen, + getActualState(), scaledPoints ) + + if (!handledAsSmartLasso) { + // Neither scribble nor smart lasso, draw as regular stroke + log.d("Drawing...") + handleDraw( + this@DrawCanvas.page, + strokeHistoryBatch, + getActualState().penSettings[getActualState().pen.penName]!!.strokeSize, + getActualState().penSettings[getActualState().pen.penName]!!.color, + getActualState().pen, + scaledPoints + ) + } else { + log.d("Handled as smart lasso selection") + } } else { log.d("Erased by scribble, $erasedByScribbleDirtyRect") drawCanvasToView(erasedByScribbleDirtyRect) diff --git a/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt b/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt index 42d8d0db..7635aca1 100644 --- a/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt +++ b/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt @@ -1,9 +1,9 @@ package com.ethran.notable.editor import android.content.Context -import android.util.Log import androidx.compose.ui.geometry.Offset import com.ethran.notable.TAG +import io.shipbook.shipbooksdk.Log import com.ethran.notable.data.datastore.GlobalAppSettings import com.ethran.notable.editor.state.EditorState import com.ethran.notable.editor.state.History @@ -146,6 +146,9 @@ class EditorControlTower( } fun deleteSelection() { + // Clear pending smart lasso data since user has committed to the selection action + clearPendingSmartLassoData() + val operationList = state.selectionState.deleteSelection(page) history.addOperationsToHistory(operationList) state.isDrawing = true @@ -166,6 +169,9 @@ class EditorControlTower( } fun duplicateSelection() { + // Clear pending smart lasso data since user has committed to the selection action + clearPendingSmartLassoData() + // finish ongoing movement applySelectionDisplace() state.selectionState.duplicateSelection() @@ -173,12 +179,18 @@ class EditorControlTower( } fun cutSelectionToClipboard(context: Context) { + // Clear pending smart lasso data since user has committed to the selection action + clearPendingSmartLassoData() + state.clipboard = state.selectionState.selectionToClipboard(page.scroll, context) deleteSelection() showHint("Content cut to clipboard", scope) } fun copySelectionToClipboard(context: Context) { + // Clear pending smart lasso data since user has committed to the selection action + clearPendingSmartLassoData() + state.clipboard = state.selectionState.selectionToClipboard(page.scroll, context) } @@ -230,5 +242,71 @@ class EditorControlTower( showHint("Pasted content from clipboard", scope) } + + /** + * Clears all pending smart lasso data when user commits to a selection action + */ + private fun clearPendingSmartLassoData() { + state.selectionState.pendingSmartLassoStroke = null + state.selectionState.pendingSmartLassoPen = null + state.selectionState.pendingSmartLassoStrokeSize = null + state.selectionState.pendingSmartLassoColor = null + } + + /** + * Dismisses the current selection. If the selection was from smart lasso, + * draws the original stroke with its original pen settings instead. + */ + fun dismissSelection() { + val pendingStroke = state.selectionState.pendingSmartLassoStroke + val pendingPen = state.selectionState.pendingSmartLassoPen + val pendingStrokeSize = state.selectionState.pendingSmartLassoStrokeSize + val pendingColor = state.selectionState.pendingSmartLassoColor + + if (pendingStroke != null && pendingPen != null && pendingStrokeSize != null && pendingColor != null) { + Log.i("SmartLasso", "User dismissed smart lasso selection, drawing the original stroke with original pen settings") + // User dismissed without using the panel, so draw the original stroke with original settings + // Save the pending data before reset clears it + val strokeToDrawCopy = pendingStroke.toList() + val penCopy = pendingPen + val strokeSizeCopy = pendingStrokeSize + val colorCopy = pendingColor + + // Reset the selection + state.selectionState.reset() + + // Now draw the pending stroke with its original pen settings + val strokeHistoryBatch = mutableListOf() + com.ethran.notable.editor.utils.handleDraw( + page, + strokeHistoryBatch, + strokeSizeCopy, + colorCopy, + penCopy, + strokeToDrawCopy + ) + + // Add to history + if (strokeHistoryBatch.isNotEmpty()) { + history.addOperationsToHistory( + operations = listOf( + Operation.DeleteStroke(strokeHistoryBatch) + ) + ) + } + + state.isDrawing = true + + // Refresh UI + scope.launch { + DrawCanvas.refreshUi.emit(Unit) + } + } else { + // Normal selection dismissal + applySelectionDisplace() + state.selectionState.reset() + state.isDrawing = true + } + } } diff --git a/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt b/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt index 8143f78a..42a48d1e 100644 --- a/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt +++ b/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt @@ -14,9 +14,11 @@ import androidx.core.graphics.createBitmap import com.ethran.notable.TAG import com.ethran.notable.data.db.Image import com.ethran.notable.data.db.Stroke +import com.ethran.notable.data.db.StrokePoint import com.ethran.notable.data.model.SimplePointF import com.ethran.notable.editor.PageView import com.ethran.notable.editor.drawing.drawImage +import com.ethran.notable.editor.utils.Pen import com.ethran.notable.editor.utils.imageBoundsInt import com.ethran.notable.editor.utils.offsetImage import com.ethran.notable.editor.utils.offsetStroke @@ -35,6 +37,13 @@ class SelectionState { var selectedStrokes by mutableStateOf?>(null) var selectedImages by mutableStateOf?>(null) + // Smart lasso: stores the original stroke data if selection was made via smart lasso + // This allows fallback to drawing the stroke if user dismisses without using the panel + var pendingSmartLassoStroke by mutableStateOf?>(null) + var pendingSmartLassoPen by mutableStateOf(null) + var pendingSmartLassoStrokeSize by mutableStateOf(null) + var pendingSmartLassoColor by mutableStateOf(null) + // TODO: Bitmap should be change, if scale changes. var selectedBitmap by mutableStateOf(null) @@ -53,6 +62,10 @@ class SelectionState { selectionStartOffset = null selectionDisplaceOffset = null placementMode = null + pendingSmartLassoStroke = null + pendingSmartLassoPen = null + pendingSmartLassoStrokeSize = null + pendingSmartLassoColor = null setAnimationMode(false) } diff --git a/app/src/main/java/com/ethran/notable/editor/ui/SelectorBitmap.kt b/app/src/main/java/com/ethran/notable/editor/ui/SelectorBitmap.kt index 12c61a1f..87ef1d60 100644 --- a/app/src/main/java/com/ethran/notable/editor/ui/SelectorBitmap.kt +++ b/app/src/main/java/com/ethran/notable/editor/ui/SelectorBitmap.kt @@ -68,9 +68,8 @@ fun SelectedBitmap( Modifier .fillMaxSize() .noRippleClickable { - controlTower.applySelectionDisplace() - selectionState.reset() - editorState.isDrawing = true + // Delegate dismissal logic to control tower + controlTower.dismissSelection() }) { Image( bitmap = selectionState.selectedBitmap!!.asImageBitmap(), diff --git a/app/src/main/java/com/ethran/notable/editor/utils/SmartLasso.kt b/app/src/main/java/com/ethran/notable/editor/utils/SmartLasso.kt new file mode 100644 index 00000000..d7f10886 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/editor/utils/SmartLasso.kt @@ -0,0 +1,180 @@ +package com.ethran.notable.editor.utils + +import android.graphics.Path +import com.ethran.notable.data.datastore.GlobalAppSettings +import com.ethran.notable.data.db.StrokePoint +import com.ethran.notable.data.model.SimplePointF +import com.ethran.notable.editor.PageView +import com.ethran.notable.editor.state.EditorState +import io.shipbook.shipbooksdk.Log +import kotlinx.coroutines.CoroutineScope +import kotlin.math.sqrt + +// Minimum number of points required for a smart lasso stroke +const val MINIMUM_SMART_LASSO_POINTS = 20 + +// Maximum distance between first and last point to consider it a closed loop +// This is a percentage of the stroke's bounding box diagonal +const val CLOSED_LOOP_THRESHOLD_PERCENTAGE = 0.15f + +// Minimum perimeter required to avoid accidental tiny loops +const val MINIMUM_PERIMETER_THRESHOLD = 100f + +/** + * Calculates the Euclidean distance between two points + */ +private fun distance(p1: StrokePoint, p2: StrokePoint): Float { + val dx = p1.x - p2.x + val dy = p1.y - p2.y + return sqrt(dx * dx + dy * dy) +} + +/** + * Calculates the total perimeter length of a stroke path + */ +private fun calculatePerimeter(points: List): Float { + var totalDistance = 0.0f + for (i in 1 until points.size) { + totalDistance += distance(points[i - 1], points[i]) + } + return totalDistance +} + +/** + * Checks if a stroke forms a closed loop suitable for smart lasso selection + */ +private fun isClosedLoop(points: List): Boolean { + if (points.size < MINIMUM_SMART_LASSO_POINTS) { + Log.d("SmartLasso", "Too few points: ${points.size} < $MINIMUM_SMART_LASSO_POINTS") + return false + } + + val firstPoint = points.first() + val lastPoint = points.last() + + // Calculate bounding box to determine if closure distance is reasonable + val boundingBox = calculateBoundingBox(points) { Pair(it.x, it.y) } + val boxWidth = boundingBox.width() + val boxHeight = boundingBox.height() + + // Calculate diagonal of bounding box + val diagonal = sqrt(boxWidth * boxWidth + boxHeight * boxHeight) + + // Check if first and last points are close enough (relative to stroke size) + val closureDistance = distance(firstPoint, lastPoint) + val closureThreshold = diagonal * CLOSED_LOOP_THRESHOLD_PERCENTAGE + + if (closureDistance > closureThreshold) { + Log.d("SmartLasso", "Not closed: distance=$closureDistance, threshold=$closureThreshold") + return false + } + + // Check minimum perimeter to avoid tiny accidental loops + val perimeter = calculatePerimeter(points) + if (perimeter < MINIMUM_PERIMETER_THRESHOLD) { + Log.d("SmartLasso", "Perimeter too small: $perimeter < $MINIMUM_PERIMETER_THRESHOLD") + return false + } + + // Additional check: ensure the stroke doesn't have too many sharp reversals + // (which would indicate scribbling rather than deliberate loop drawing) + // We allow some reversals (unlike scribble which requires many), but not too many + val reversals = calculateNumReversalsForLasso(points) + if (reversals > 8) { // Allow some natural hand movements but reject scribbles + Log.d("SmartLasso", "Too many reversals: $reversals > 8 (likely scribble, not lasso)") + return false + } + + Log.d("SmartLasso", "Detected closed loop: points=${points.size}, perimeter=$perimeter, closure=$closureDistance") + return true +} + +/** + * Calculates direction reversals but with different thresholds for lasso detection + * Unlike scribble detection, we're looking for smooth loops, not back-and-forth motion + */ +private fun calculateNumReversalsForLasso( + points: List, + stepSize: Int = 8 +): Int { + var numReversals = 0 + for (i in 0 until points.size - 2 * stepSize step stepSize) { + val p1 = points[i] + val p2 = points[i + stepSize] + val p3 = points[i + 2 * stepSize] + val segment1 = SimplePointF(p2.x - p1.x, p2.y - p1.y) + val segment2 = SimplePointF(p3.x - p2.x, p3.y - p2.y) + val dotProduct = segment1.x * segment2.x + segment1.y * segment2.y + // Count sharp reversals (angle > 120 degrees) instead of just > 90 + if (dotProduct < -0.5f * sqrt( + (segment1.x * segment1.x + segment1.y * segment1.y) * + (segment2.x * segment2.x + segment2.y * segment2.y) + )) { + numReversals++ + } + } + return numReversals +} + +/** + * Attempts to handle a stroke as a smart lasso selection. + * Returns true if the stroke was handled as a smart lasso (and selection was triggered), + * false if the stroke should be drawn normally. + * + * @param scope Coroutine scope for async operations + * @param page Current page view + * @param editorState Current editor state + * @param touchPoints The stroke points to analyze (in page coordinates) + * @return true if handled as smart lasso, false if should be drawn as regular stroke + */ +fun handleSmartLasso( + scope: CoroutineScope, + page: PageView, + editorState: EditorState, + touchPoints: List +): Boolean { + // Check if feature is enabled + if (!GlobalAppSettings.current.smartLassoEnabled) { + return false + } + + // Don't interfere with marker (highlighter) strokes + if (editorState.pen == Pen.MARKER) { + return false + } + + // Check if this stroke forms a closed loop + if (!isClosedLoop(touchPoints)) { + return false + } + + // Convert points to SimplePointF for handleSelect + val selectionPoints = touchPoints.map { SimplePointF(it.x, it.y) } + + // Create selection path and find selected content + val selectionPath = pointsToPath(selectionPoints) + selectionPath.close() + + val selectedStrokes = selectStrokesFromPath(page.strokes, selectionPath) + val selectedImages = selectImagesFromPath(page.images, selectionPath) + + // If nothing was selected, don't treat this as a smart lasso + // (let it be drawn as a normal stroke instead) + if (selectedStrokes.isEmpty() && selectedImages.isEmpty()) { + Log.d("SmartLasso", "No content selected, drawing as regular stroke") + return false + } + + Log.i("SmartLasso", "Smart lasso triggered! Selected ${selectedStrokes.size} strokes and ${selectedImages.size} images") + + // Store the original stroke data (points + pen settings) so they can be drawn if user dismisses without using panel + editorState.selectionState.pendingSmartLassoStroke = touchPoints + editorState.selectionState.pendingSmartLassoPen = editorState.pen + editorState.selectionState.pendingSmartLassoStrokeSize = editorState.penSettings[editorState.pen.penName]?.strokeSize + editorState.selectionState.pendingSmartLassoColor = editorState.penSettings[editorState.pen.penName]?.color + + // Trigger selection + selectImagesAndStrokes(scope, page, editorState, selectedImages, selectedStrokes) + + return true +} diff --git a/app/src/main/java/com/ethran/notable/ui/views/Settings.kt b/app/src/main/java/com/ethran/notable/ui/views/Settings.kt index 58de824f..fd7360f3 100644 --- a/app/src/main/java/com/ethran/notable/ui/views/Settings.kt +++ b/app/src/main/java/com/ethran/notable/ui/views/Settings.kt @@ -202,6 +202,13 @@ fun GeneralSettings(kv: KvProxy, settings: AppSettings) { kv.setAppSettings(settings.copy(scribbleToEraseEnabled = isChecked)) }) + SettingToggleRow( + label = stringResource(R.string.enable_smart_lasso), + value = settings.smartLassoEnabled, + onToggle = { isChecked -> + kv.setAppSettings(settings.copy(smartLassoEnabled = isChecked)) + }) + SettingToggleRow( label = stringResource(R.string.enable_smooth_scrolling), value = settings.smoothScroll, diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 982831b0..710481cb 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -17,6 +17,7 @@ Używaj Onyx NeoTools (może powodować awarie) Zamaż aby usunąć + Włącz inteligentne zaznaczanie lasso (narysuj zamkniętą pętlę, aby zaznaczyć zawartość) Włącz płynne przewijanie Włącz płynne przybliżanie Płynny regulator grubości kreski diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12d84e01..4e5e4712 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ Hexagon grid Use Onyx NeoTools (may cause crashes) Enable scribble-to-erase (scribble out your mistakes to erase them) + Enable smart lasso selection (draw a closed loop to select content) Enable smooth scrolling Continuous Zoom Continuous Stroke Slider