diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt index bc4e5c6761..5dfb9199e6 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt @@ -49,6 +49,7 @@ internal class ImageController( } var mode: Mode by mutableStateOf(Mode.NONE) + private set var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked) private set @@ -59,10 +60,15 @@ internal class ImageController( private var initialDialScale: Float = editorModel.mainImage?.localScaleX ?: 1f private var initialDialImageDegrees: Float = 0f private var minDialScaleDown: Float = 1f - private var drawSessionSnapshot: ByteArray? = null + private var drawSessionSnapshot: ByteArray? by mutableStateOf(null) private var drawSessionDirty: Boolean by mutableStateOf(false) private var modeBeforeStickerInsertion: Mode = Mode.NONE - private var modeBeforeDrag: Mode = Mode.NONE + + /** + * The mode a transient one falls back to. Selecting, dragging, inserting a sticker and entering text are all things + * the user does *within* an editing session, so they must not be able to strand that session -- or start one. + */ + private var restingMode: Mode = Mode.NONE var textEditingElement: EditorElement? by mutableStateOf(null) private set @@ -76,7 +82,14 @@ internal class ImageController( var showDiscardDialog: Boolean by mutableStateOf(false) private set - private val isInDrawSession: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR } + /** + * Whether there is an uncommitted session to commit or discard. Keyed off the snapshot rather than [mode], because + * the transient modes -- placing a sticker, entering text, dragging -- all sit on top of a live session. + */ + private val isInDrawSession: Boolean by derivedStateOf { drawSessionSnapshot != null } + + /** Whether the brush itself is the active tool, which is a narrower question than [isInDrawSession]. */ + private val isPaintMode: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR } val hasUnsavedChanges: Boolean by derivedStateOf { when { @@ -88,7 +101,7 @@ internal class ImageController( } val shouldDisplayTextColorBar: Boolean by derivedStateOf { - textEditingElement != null || mode == Mode.MOVE_TEXT + textEditingElement != null } val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT } @@ -110,11 +123,37 @@ internal class ImageController( val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT } val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER } - val isUserDraggingElement: Boolean by derivedStateOf { mode == Mode.DELETE } - var isDraggedElementOverTrash: Boolean by mutableStateOf(false) private set + /** Whether back should back out of an editor mode rather than leaving the screen. */ + val canHandleBack: Boolean by derivedStateOf { + when (mode) { + // The sticker picker is its own window and owns back while it is up, and DELETE only exists mid-drag. + Mode.INSERT_STICKER, Mode.DELETE -> false + Mode.NONE -> selectedElement != null + else -> true + } + } + + /** + * Backs out of the current mode into the one before it. Draw and crop sessions go through [requestCancelEdit] so that + * backing out of a dirty one asks first rather than silently throwing the work away. + */ + fun onBackPressed() { + // A selection is a level of its own: back gives that up before it gives up the mode. + if (selectedElement != null) { + clearSelection() + return + } + + when (mode) { + Mode.TEXT -> finishTextEditing() + Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR, Mode.CROP -> requestCancelEdit() + Mode.NONE, Mode.INSERT_STICKER, Mode.DELETE -> Unit + } + } + fun requestCancelEdit() { if (hasUnsavedChanges) { showDiscardDialog = true @@ -173,31 +212,59 @@ internal class ImageController( drawSessionSnapshot = null drawSessionDirty = false selectedElement = null - mode = Mode.NONE + transitionTo(Mode.NONE) imageEditorState.isDrawing = false imageEditorState.isBlur = false } + private fun transitionTo(newMode: Mode) { + if (!newMode.isTransient) { + restingMode = newMode + } + + mode = newMode + } + + /** Ends a transient mode by returning to the session the user was in before it. */ + private fun returnToRestingMode() { + when (restingMode) { + Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR -> { + transitionTo(restingMode) + syncDrawingState() + } + + // The crop is still open on the model, so there is nothing to restart -- only the mode to hand back. + Mode.CROP -> transitionTo(Mode.CROP) + + else -> exitEditMode() + } + + imageEditorState.invalidate() + } + fun enterDrawMode() { + clearSelection() snapshotIfNewDrawSession() - mode = Mode.DRAW + transitionTo(Mode.DRAW) syncDrawingState() } fun enterHighlightMode() { + clearSelection() snapshotIfNewDrawSession() - mode = Mode.HIGHLIGHT + transitionTo(Mode.HIGHLIGHT) syncDrawingState() } fun enterBlurMode() { + clearSelection() snapshotIfNewDrawSession() - mode = Mode.BLUR + transitionTo(Mode.BLUR) syncDrawingState() } private fun snapshotIfNewDrawSession() { - if (!isInDrawSession) { + if (drawSessionSnapshot == null) { drawSessionSnapshot = editorModel.createSnapshot() drawSessionDirty = false } @@ -228,8 +295,11 @@ internal class ImageController( } private fun syncDrawingState() { - imageEditorState.isDrawing = true - imageEditorState.isBlur = mode == Mode.BLUR + // A selected element takes the touch: with the brush live, a sticker the user just placed could not be moved. + val canPaint = selectedElement == null + + imageEditorState.isDrawing = canPaint + imageEditorState.isBlur = canPaint && mode == Mode.BLUR imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND imageEditorState.drawThickness = brushThickness setDrawColor(drawColorBarState.color) @@ -238,7 +308,7 @@ internal class ImageController( fun enterCropMode() { editorModel.startCrop() initialDialScale = editorModel.mainImage?.localScaleX ?: 1f - mode = Mode.CROP + transitionTo(Mode.CROP) } fun enterTextMode() { @@ -250,7 +320,7 @@ internal class ImageController( } private fun beginTextEditing(element: EditorElement) { - mode = Mode.TEXT + transitionTo(Mode.TEXT) textEditingElement = element imageEditorState.textEditingElement = element editorModel.addFade() @@ -262,21 +332,31 @@ internal class ImageController( val element = textEditingElement ?: return val renderer = element.renderer as? MultiLineTextRenderer val hasText = renderer?.text?.isNotEmpty() == true - val snapshot = drawSessionSnapshot renderer?.setFocused(false) editorModel.zoomOut() editorModel.removeFade() editorModel.setSelectionVisible(true) - if (!hasText && snapshot != null) { - editorModel.restoreFromSnapshot(snapshot) - } - - editorModel.setSelected(null) textEditingElement = null imageEditorState.textEditingElement = null - exitEditMode() + + if (hasText) { + drawSessionDirty = true + + // Staying selected keeps the brush off it, so it can still be moved or double-tapped back open. + selectElement(element) + } else { + // Drop just the abandoned element. Restoring the draw session snapshot here would take every stroke made before + // text entry with it. + clearSelection() + editorModel.delete(element) + editorModel.updateUndoRedoAvailabilityState() + } + + // Returning to the session that text entry was started from keeps its commit and discard available, rather than + // banking the work without the user ever confirming it. + returnToRestingMode() } fun onTextChanged(text: String) { @@ -324,47 +404,64 @@ internal class ImageController( imageEditorState.invalidate() } + /** Re-opens an existing text element, which is how the user gets back to its color and style controls. */ + fun onEntityDoubleTap(element: EditorElement?) { + if (mode == Mode.CROP || element == null || element.renderer !is MultiLineTextRenderer) { + return + } + + beginTextEditing(element) + } + private fun selectElement(element: EditorElement) { (selectedElement?.renderer as? SelectableRenderer)?.onSelected(false) (element.renderer as? SelectableRenderer)?.onSelected(true) editorModel.setSelected(element) selectedElement = element - mode = when (element.renderer) { - is MultiLineTextRenderer -> Mode.MOVE_TEXT - else -> Mode.MOVE_STICKER + + if (isPaintMode) { + syncDrawingState() } + + imageEditorState.invalidate() } private fun clearSelection() { - if (selectedElement != null) { - (selectedElement?.renderer as? SelectableRenderer)?.onSelected(false) - editorModel.setSelected(null) - selectedElement = null - mode = Mode.NONE - imageEditorState.invalidate() + val element = selectedElement ?: return + + (element.renderer as? SelectableRenderer)?.onSelected(false) + editorModel.setSelected(null) + selectedElement = null + + if (isPaintMode) { + syncDrawingState() } + + imageEditorState.invalidate() } fun enterStickerMode() { - modeBeforeStickerInsertion = mode - mode = Mode.INSERT_STICKER + // Re-opening the picker must not record INSERT_STICKER as the mode to come back to. + if (mode != Mode.INSERT_STICKER) { + modeBeforeStickerInsertion = mode + } + + transitionTo(Mode.INSERT_STICKER) } fun insertSticker(renderer: Renderer) { val element = EditorElement(renderer, EditorModel.Z_STICKERS) editorModel.addElementCentered(element, STICKER_SCALE) + drawSessionDirty = true - // The picker can be opened mid-draw-session, where touches would otherwise keep painting. - imageEditorState.isDrawing = false - imageEditorState.isBlur = false - + // Selecting first leaves the new sticker holding the touch, so a draw session resumed here does not paint over it. selectElement(element) - imageEditorState.invalidate() + returnToRestingMode() } fun cancelStickerInsertion() { if (mode == Mode.INSERT_STICKER) { - mode = modeBeforeStickerInsertion + transitionTo(modeBeforeStickerInsertion) } } @@ -374,10 +471,9 @@ internal class ImageController( return } - modeBeforeDrag = mode selectElement(element) isDraggedElementOverTrash = false - mode = Mode.DELETE + transitionTo(Mode.DELETE) setTrashVisible(true) } @@ -403,24 +499,21 @@ internal class ImageController( isDraggedElementOverTrash = false setTrashVisible(false) - if (element == null) { - exitEditMode() - return - } - - if (isOverTrash) { - editorModel.delete(element) - editorModel.updateUndoRedoAvailabilityState() - - when (modeBeforeDrag) { - Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR -> { - selectedElement = null - mode = modeBeforeDrag - } - else -> exitEditMode() + if (element == null || isOverTrash) { + element?.let { + editorModel.delete(it) + editorModel.updateUndoRedoAvailabilityState() + drawSessionDirty = true } + + clearSelection() + returnToRestingMode() } else { element.animatePartialFadeIn(imageEditorState::invalidate) + + // Moving something is not an edit mode of its own, so the drag hands back whatever the user was in -- but the + // element they were just dragging stays selected. + returnToRestingMode() selectElement(element) } } @@ -480,10 +573,12 @@ internal class ImageController( DRAW, HIGHLIGHT, BLUR, - MOVE_STICKER, - MOVE_TEXT, DELETE, - INSERT_STICKER + INSERT_STICKER; + + /** Whether this is something done within a session rather than a session in its own right. */ + internal val isTransient: Boolean + get() = this == TEXT || this == DELETE || this == INSERT_STICKER } /** diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditControl.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditControl.kt new file mode 100644 index 0000000000..2bbf5a8ac6 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditControl.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import org.signal.mediasend.MediaSendMetrics + +/** + * A control on the edit screen, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout + * space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture + * would move whatever the user is dragging out from under their finger. + */ +@Composable +internal fun MediaEditControl( + faded: Boolean, + modifier: Modifier = Modifier, + visible: Boolean = true, + enter: EnterTransition = MediaSendMetrics.ControlEnterTransition, + exit: ExitTransition = MediaSendMetrics.ControlExitTransition, + content: @Composable () -> Unit +) { + val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f) + + AnimatedVisibility( + visible = visible, + enter = enter, + exit = exit, + modifier = modifier.alpha(alpha) + ) { + content() + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index c8cbcd9ea5..609351b245 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -8,10 +8,6 @@ package org.signal.mediasend.edit import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.SnapPosition import androidx.compose.foundation.layout.Arrangement.spacedBy @@ -36,7 +32,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode @@ -115,11 +110,21 @@ internal fun MediaEditScreen( null } + // Composed after the camera-first handler so it wins while an editor mode is open: back should step out of that mode + // rather than leave the screen. + BackHandler(enabled = imageController?.canHandleBack == true) { + imageController?.onBackPressed() + } + var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) } var isAdjustingBrushWidth by remember(focusedUri) { mutableStateOf(false) } val isImageEditing = imageController?.isUserInEdit == true val isInteracting = isImageEditing || isVideoInteracting + // Drags of the media itself, which every piece of chrome gets out of the way for. Sliders are excluded -- they are + // chrome themselves, and clearing the screen would hide what they adjust. + val isDragging = imageController?.imageEditorState?.isGestureActive == true || isVideoInteracting + HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), @@ -205,19 +210,23 @@ internal fun MediaEditScreen( modifier = Modifier.fillMaxSize() ) - BrushWidthBar( - fraction = imageController.brushWidthFraction, - onFractionChanged = { fraction, gestureComplete -> - isAdjustingBrushWidth = !gestureComplete - val tool = imageController.brushTool - imageController.setBrushWidthFraction(fraction) - - if (gestureComplete && tool != null) { - onEvent(MediaEditScreenEvent.BrushWidthChanged(tool, fraction)) - } - }, + MediaEditControl( + faded = isDragging, modifier = Modifier.align(Alignment.CenterStart) - ) + ) { + BrushWidthBar( + fraction = imageController.brushWidthFraction, + onFractionChanged = { fraction, gestureComplete -> + isAdjustingBrushWidth = !gestureComplete + val tool = imageController.brushTool + imageController.setBrushWidthFraction(fraction) + + if (gestureComplete && tool != null) { + onEvent(MediaEditScreenEvent.BrushWidthChanged(tool, fraction)) + } + } + ) + } } val isTextEditing = imageController?.textEditingElement != null @@ -245,7 +254,7 @@ internal fun MediaEditScreen( } if (state.selectedMedia.size > 1) { - MediaEditControl(visible = !isImageEditing, faded = isVideoInteracting) { + MediaEditControl(visible = !isImageEditing, faded = isDragging) { ThumbnailRow( selectedMedia = state.selectedMedia, pagerState = pagerState, @@ -280,7 +289,9 @@ internal fun MediaEditScreen( } if (controller.isUserDrawing) { - DrawModeColorBar(imageEditorController = controller) + MediaEditControl(faded = isDragging) { + DrawModeColorBar(imageEditorController = controller) + } } } @@ -291,14 +302,15 @@ internal fun MediaEditScreen( state = state, onEvent = onEvent, imageController = imageController, - isTextEditing = isTextEditing + isTextEditing = isTextEditing, + isDragging = isDragging ) } } MediaEditControl( visible = !isImageEditing, - faded = isVideoInteracting, + faded = isDragging, enter = MediaSendMetrics.SlidingControlEnterTransition, exit = MediaSendMetrics.SlidingControlExitTransition ) { @@ -324,6 +336,7 @@ internal fun MediaEditScreen( onEvent = onEvent, imageController = imageController, isTextEditing = isTextEditing, + isDragging = isDragging, modifier = Modifier .align(Alignment.CenterEnd) ) @@ -332,18 +345,23 @@ internal fun MediaEditScreen( val displayNameState = state.recipientId?.let { LocalDisplayNameProvider.current(it.id) } ?: remember { mutableStateOf(null) } val displayName: String? by displayNameState - MediaEditSummaryPill( - displayName = displayName, - selectedMedia = state.selectedMedia, - selectedPage = pagerState.currentPage, + MediaEditControl( + faded = isDragging, modifier = Modifier .align(Alignment.TopCenter) .padding(top = 10.dp) .systemBarsPadding() - ) + ) { + MediaEditSummaryPill( + displayName = displayName, + selectedMedia = state.selectedMedia, + selectedPage = pagerState.currentPage + ) + } ImageEditorUndoRedoButtons( imageEditorController = imageController, + isDragging = isDragging, modifier = Modifier .align(Alignment.TopStart) .padding(top = 12.dp, start = 16.dp) @@ -352,6 +370,7 @@ internal fun MediaEditScreen( ImageEditorClearAllButton( imageEditorController = imageController, + isDragging = isDragging, modifier = Modifier .align(Alignment.TopEnd) .padding(top = 12.dp, end = 16.dp) @@ -375,60 +394,37 @@ private fun MediaToolbar( focusedEditorState: EditorState?, imageController: ImageController?, isTextEditing: Boolean, + isDragging: Boolean, modifier: Modifier = Modifier ) { - if (focusedUri == null) { + if (focusedUri == null || focusedEditorState == null) { return } - when (focusedEditorState) { - null -> return - is EditorState.Image -> { - imageController?.let { - ImageEditorToolbar( - imageEditorController = it, + MediaEditControl(faded = isDragging, modifier = modifier) { + when (focusedEditorState) { + is EditorState.Image -> { + imageController?.let { + ImageEditorToolbar( + imageEditorController = it, + state = state, + onEvent = onEvent, + modifier = Modifier + .navigationBarsPadding() + .padding(end = 24.dp) + .then(if (isTextEditing) Modifier.imePadding() else Modifier) + ) + } + } + + else -> MediaEditorToolbar { + MediaEditorToolbarSharedButtons( state = state, onEvent = onEvent, - modifier = modifier - .navigationBarsPadding() - .padding(end = 24.dp) - .then(if (isTextEditing) Modifier.imePadding() else Modifier) + canSave = focusedEditorState is EditorState.VideoTrim ) } } - - else -> MediaEditorToolbar(modifier = modifier) { - MediaEditorToolbarSharedButtons( - state = state, - onEvent = onEvent, - canSave = focusedEditorState is EditorState.VideoTrim - ) - } - } -} - -/** - * A control in the bottom stack, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout - * space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture - * would move whatever the user is dragging out from under their finger. - */ -@Composable -private fun MediaEditControl( - visible: Boolean, - faded: Boolean, - enter: EnterTransition = MediaSendMetrics.ControlEnterTransition, - exit: ExitTransition = MediaSendMetrics.ControlExitTransition, - content: @Composable () -> Unit -) { - val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f) - - AnimatedVisibility( - visible = visible, - enter = enter, - exit = exit, - modifier = Modifier.alpha(alpha) - ) { - content() } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt index f1c47a980d..067102e8c9 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt @@ -35,11 +35,13 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import org.signal.imageeditor.core.ImageEditorTouchHandler import org.signal.imageeditor.core.model.EditorElement +import org.signal.imageeditor.core.renderers.MultiLineTextRenderer import org.signal.mediasend.edit.ImageController @Composable @@ -91,7 +93,14 @@ internal fun ImageEditor( @Composable private fun HiddenTextInput(controller: ImageController) { - var text by remember { mutableStateOf(TextFieldValue("")) } + val element = controller.textEditingElement + + // Re-opening an existing element has to start from its current text, or the first keystroke would replace it. + var text by remember(element) { + val existing = (element?.renderer as? MultiLineTextRenderer)?.text ?: "" + mutableStateOf(TextFieldValue(existing, TextRange(existing.length))) + } + val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -109,8 +118,11 @@ private fun HiddenTextInput(controller: ImageController) { keyboardOptions = KeyboardOptions(imeAction = ImeAction.None) ) - LaunchedEffect(Unit) { + LaunchedEffect(element) { focusRequester.requestFocus() + + // Also zooms the element clear of the keyboard, which otherwise would not happen until the first keystroke. + controller.onTextSelectionChanged(text.selection.start, text.selection.end) } DisposableEffect(Unit) { @@ -125,6 +137,10 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller return this.pointerInput(controller, controller.textEditingElement) { val touchHandler = ImageEditorTouchHandler() + // The tap that a second one has to land on, and beat the timeout from, to count as a double tap. + var lastTapElement: EditorElement? = null + var lastTapUptimeMillis = 0L + awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = true) @@ -163,6 +179,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller var inDrag = false var draggedElement: EditorElement? = null var droppedOnTrash = false + var didPinch = false try { while (true) { @@ -180,12 +197,28 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller state.editorModel.findElementAtPoint(upPoint, state.viewMatrix, Matrix()) === draggedElement } - val wasSingleTap = !inDrag && !touchHandler.isDrawingSession() + // A tap that neither painted, dragged nor pinched leaves the model exactly as it found it, so it must not + // count as an edit -- otherwise merely selecting something would arm the "Discard changes?" prompt. + val didModifyModel = inDrag || didPinch || touchHandler.isDrawingSession() + val wasSingleTap = !didModifyModel touchHandler.onUp(state.editorModel) - state.onGestureCompleted?.invoke() - if (wasSingleTap) { + if (didModifyModel) { + state.onGestureCompleted?.invoke() + } + + val isDoubleTap = wasSingleTap && + hitElement != null && + hitElement === lastTapElement && + down.uptimeMillis - lastTapUptimeMillis <= viewConfiguration.doubleTapTimeoutMillis + + lastTapElement = if (wasSingleTap && !isDoubleTap) hitElement else null + lastTapUptimeMillis = event.changes.firstOrNull()?.uptimeMillis ?: down.uptimeMillis + + if (isDoubleTap) { + controller.onEntityDoubleTap(hitElement) + } else if (wasSingleTap) { controller.onEntitySingleTap(hitElement) } @@ -193,6 +226,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller } if (currentCount == 2 && previousPointerCount < 2) { + didPinch = true val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last() val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1) touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt index 4eb882d77e..4ab7bf5171 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt @@ -19,17 +19,19 @@ import org.signal.core.ui.compose.theme.SignalTheme import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.R import org.signal.mediasend.edit.ImageController +import org.signal.mediasend.edit.MediaEditControl @Composable internal fun ImageEditorClearAllButton( imageEditorController: ImageController?, + isDragging: Boolean, modifier: Modifier = Modifier, canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false ) { - ImageEditorGestureAwareControl( - imageEditorController = imageEditorController, - modifier = modifier, - extraCheck = { canUndo } + MediaEditControl( + visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && canUndo, + faded = isDragging, + modifier = modifier ) { Buttons.MediumTonal( colors = ButtonDefaults.buttonColors( @@ -51,6 +53,7 @@ private fun ImageEditorClearAllButtonPreview() { Previews.Preview { ImageEditorClearAllButton( imageEditorController = remember { ImageController(EditorModel.create(0x0)).apply { enterDrawMode() } }, + isDragging = false, canUndo = true ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt deleted file mode 100644 index 60c3a5a7f5..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.mediasend.edit.image - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import org.signal.mediasend.MediaSendMetrics -import org.signal.mediasend.edit.ImageController - -/** - * Hides or shows content based on whether the user is performing a gesture. - */ -@Composable -internal fun ImageEditorGestureAwareControl( - imageEditorController: ImageController?, - modifier: Modifier = Modifier, - extraCheck: () -> Boolean = { true }, - content: @Composable () -> Unit -) { - val isGestureActive = imageEditorController?.imageEditorState?.isGestureActive ?: false - - AnimatedVisibility( - modifier = modifier, - enter = MediaSendMetrics.ControlEnterTransition, - exit = MediaSendMetrics.ControlExitTransition, - visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && !isGestureActive && extraCheck() - ) { - content() - } -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt index c970a615fc..13f4325536 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt @@ -43,8 +43,6 @@ internal fun ImageEditorToolbar( modifier: Modifier = Modifier ) { when { - // The trash is the only thing on screen during a drag. - imageEditorController.isUserDraggingElement -> Unit imageEditorController.shouldDisplayTextColorBar -> { TextModeColorBar( imageEditorController = imageEditorController, @@ -131,10 +129,10 @@ private fun ImageEditorDrawStateToolbar( imageVector = SignalIcons.Sticker.imageVector, checked = imageEditorController.isUserInsertingSticker, onCheckChanged = { - if (!imageEditorController.isUserInsertingSticker) { - imageEditorController.enterStickerMode() - onEvent(MediaEditScreenEvent.StickerClick) - } + // Unconditional: if a previous pick never delivered a result the mode is still INSERT_STICKER, and gating on it + // would leave the button dead. + imageEditorController.enterStickerMode() + onEvent(MediaEditScreenEvent.StickerClick) } ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt index 0fd2d62d2b..019f415b98 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt @@ -22,17 +22,21 @@ import org.signal.core.ui.compose.theme.SignalTheme import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.R import org.signal.mediasend.edit.ImageController +import org.signal.mediasend.edit.MediaEditControl @Composable internal fun ImageEditorUndoRedoButtons( imageEditorController: ImageController?, + isDragging: Boolean, modifier: Modifier = Modifier, canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false, canRedo: Boolean = imageEditorController?.imageEditorState?.redoAvailable ?: false ) { - ImageEditorGestureAwareControl(imageEditorController, modifier = modifier, extraCheck = { - canUndo || canRedo - }) { + MediaEditControl( + visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && (canUndo || canRedo), + faded = isDragging, + modifier = modifier + ) { Row(horizontalArrangement = spacedBy(8.dp)) { IconButton( enabled = canUndo, @@ -73,6 +77,7 @@ private fun ImageEditorUndoRedoButtonsPreview() { enterDrawMode() } }, + isDragging = false, canUndo = true, canRedo = true )