Add undo/redo and clearall.

This commit is contained in:
Alex Hart
2026-08-04 20:03:31 -03:00
parent 340485f5a5
commit 92d6076c9c
12 changed files with 276 additions and 26 deletions
@@ -76,6 +76,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
QualityHigh(icon(R.drawable.symbol_quality_high_24)),
QualityHighSlash(icon(R.drawable.symbol_quality_high_slash_24)),
Recent(icon(R.drawable.symbol_recent_24)),
Redo(icon(R.drawable.symbol_redo_24)),
Save(icon(R.drawable.symbol_save_android_24)),
Search(icon(R.drawable.symbol_search_24)),
Settings(icon(R.drawable.symbol_settings_android_24)),
@@ -86,6 +87,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Transfer(icon(R.drawable.symbol_transfer_24)),
TransferDisplay(icon(R.drawable.symbol_transfer_display_48)),
Trash(icon(R.drawable.symbol_trash_24)),
Undo(icon(R.drawable.symbol_undo_24)),
X(icon(R.drawable.symbol_x_24)),
XCircleFill(icon(R.drawable.symbol_x_circle_fill_24))
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M21.88 9c0-0.23-0.1-0.45-0.26-0.62l-5.5-5.5c-0.34-0.34-0.9-0.34-1.24 0-0.34 0.34-0.34 0.9 0 1.24l4.13 4.13-1.76-0.13H8c-3.52 0-6.38 2.86-6.38 6.38S4.48 20.88 8 20.88h4c0.48 0 0.88-0.4 0.88-0.88s-0.4-0.88-0.88-0.88H8c-2.55 0-4.63-2.07-4.63-4.62 0-2.55 2.08-4.63 4.63-4.63h9.25l1.76-0.12-4.13 4.13c-0.34 0.34-0.34 0.9 0 1.24 0.34 0.34 0.9 0.34 1.24 0l5.5-5.5c0.16-0.17 0.25-0.39 0.25-0.62Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/signal_dark_colorOnSurface"
android:pathData="M2.13 9c0-0.23 0.09-0.45 0.25-0.62l5.5-5.5c0.34-0.34 0.9-0.34 1.24 0 0.34 0.34 0.34 0.9 0 1.24L4.99 8.25l1.76-0.13H16c3.52 0 6.38 2.86 6.38 6.38s-2.86 6.38-6.38 6.38h-4c-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88h4c2.55 0 4.63-2.07 4.63-4.62 0-2.55-2.08-4.63-4.63-4.63H6.75L4.99 9.76l4.13 4.13c0.34 0.34 0.34 0.9 0 1.24-0.34 0.34-0.9 0.34-1.24 0l-5.5-5.5C2.22 9.45 2.13 9.23 2.13 9Z"/>
</vector>
@@ -5,6 +5,10 @@
package org.signal.mediasend
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
@@ -12,4 +16,7 @@ import androidx.compose.ui.unit.dp
object MediaSendMetrics {
val SelectedMediaPreviewSize = DpSize(44.dp, 44.dp)
val SelectedMediaPreviewShape = RoundedCornerShape(8.dp)
val ControlEnterTransition: EnterTransition = fadeIn()
val ControlExitTransition: ExitTransition = fadeOut()
}
@@ -32,7 +32,7 @@ import org.signal.mediasend.edit.image.ImageEditorState
*/
@Stable
class ImageController @RememberInComposition constructor(
val editorModel: EditorModel,
private val editorModel: EditorModel,
private val brushWidths: BrushWidthsState = BrushWidthsState()
) {
@@ -190,6 +190,20 @@ class ImageController @RememberInComposition constructor(
}
}
fun undo() {
editorModel.undo()
}
fun redo() {
editorModel.redo()
}
fun clearAllEdits() {
while (imageEditorState.undoAvailable) {
editorModel.undo()
}
}
fun setDrawColor(color: Int) {
imageEditorState.drawColor = brushTool?.applyAlpha(color) ?: color
}
@@ -321,6 +335,7 @@ class ImageController @RememberInComposition constructor(
}
fun onDialGestureStart() {
imageEditorState.isGestureActive = true
val mainImage = editorModel.mainImage ?: return
initialDialScale = mainImage.localScaleX
minDialScaleDown = 1f
@@ -334,6 +349,7 @@ class ImageController @RememberInComposition constructor(
}
fun onDialGestureEnd() {
imageEditorState.isGestureActive = false
val mainImage = editorModel.mainImage ?: return
mainImage.commitEditorMatrix()
editorModel.postEdit(true)
@@ -58,7 +58,9 @@ import org.signal.mediasend.edit.image.BrushWidthPreview
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.image.DrawModeColorBar
import org.signal.mediasend.edit.image.ImageEditor
import org.signal.mediasend.edit.image.ImageEditorClearAllButton
import org.signal.mediasend.edit.image.ImageEditorToolbar
import org.signal.mediasend.edit.image.ImageEditorUndoRedoButtons
import org.signal.mediasend.edit.image.RotationDial
import org.signal.mediasend.edit.video.VideoEditorFragment
import org.signal.mediasend.edit.video.VideoEditorToolbar
@@ -353,6 +355,22 @@ fun MediaEditScreen(
.systemBarsPadding()
)
ImageEditorUndoRedoButtons(
imageEditorController = imageController,
modifier = Modifier
.align(Alignment.TopStart)
.padding(top = 12.dp, start = 16.dp)
.systemBarsPadding()
)
ImageEditorClearAllButton(
imageEditorController = imageController,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 12.dp, end = 16.dp)
.systemBarsPadding()
)
if (state.isSavingMedia) {
MediaEditScreenDialogs.SavingToStorageProgressDialog()
}
@@ -143,37 +143,42 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
}
down.consume()
state.isGestureActive = true
var previousPointerCount = 1
while (true) {
val event = awaitPointerEvent()
val currentPressed = event.changes.filter { it.pressed }
val currentCount = currentPressed.size
try {
while (true) {
val event = awaitPointerEvent()
val currentPressed = event.changes.filter { it.pressed }
val currentCount = currentPressed.size
if (currentCount == 0) {
event.changes.forEach { it.consume() }
touchHandler.onUp(state.editorModel)
state.onGestureCompleted?.invoke()
break
}
if (currentCount == 2 && previousPointerCount < 2) {
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)
} else if (currentCount == 1 && previousPointerCount == 2) {
val released = event.changes.firstOrNull { !it.pressed && it.previousPressed }
val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0
touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex)
} else if (touchHandler.hasActiveSession()) {
val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray()
touchHandler.onMove(state.editorModel, pointers)
state.invalidate()
}
if (currentCount == 0) {
event.changes.forEach { it.consume() }
touchHandler.onUp(state.editorModel)
state.onGestureCompleted?.invoke()
break
previousPointerCount = currentCount
}
if (currentCount == 2 && previousPointerCount < 2) {
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)
} else if (currentCount == 1 && previousPointerCount == 2) {
val released = event.changes.firstOrNull { !it.pressed && it.previousPressed }
val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0
touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex)
} else if (touchHandler.hasActiveSession()) {
val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray()
touchHandler.onMove(state.editorModel, pointers)
state.invalidate()
}
event.changes.forEach { it.consume() }
previousPointerCount = currentCount
} finally {
state.isGestureActive = false
}
}
}
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
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
@Composable
internal fun ImageEditorClearAllButton(
imageEditorController: ImageController?,
modifier: Modifier = Modifier,
canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false
) {
ImageEditorGestureAwareControl(
imageEditorController = imageEditorController,
modifier = modifier,
extraCheck = { canUndo }
) {
Buttons.MediumTonal(
colors = ButtonDefaults.buttonColors(
containerColor = SignalTheme.colors.colorSurface5,
contentColor = MaterialTheme.colorScheme.onSurface
),
onClick = {
imageEditorController?.clearAllEdits()
}
) {
Text(text = stringResource(R.string.ImageEditorClearAllButton__clear_all))
}
}
}
@DayNightPreviews
@Composable
private fun ImageEditorClearAllButtonPreview() {
Previews.Preview {
ImageEditorClearAllButton(
imageEditorController = remember { ImageController(EditorModel.create(0x0)).apply { enterDrawMode() } },
canUndo = true
)
}
}
@@ -0,0 +1,34 @@
/*
* 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
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()
}
}
@@ -45,6 +45,10 @@ class ImageEditorState(
var redoAvailable: Boolean by mutableStateOf(false)
private set
/** True while the user is actively manipulating the image: drawing, moving, scaling, or rotating. */
var isGestureActive: Boolean by mutableStateOf(false)
internal set
var textEditingElement: EditorElement? = null
var isDrawing: Boolean = false
var isBlur: Boolean = false
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
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
@Composable
internal fun ImageEditorUndoRedoButtons(
imageEditorController: ImageController?,
modifier: Modifier = Modifier,
canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false,
canRedo: Boolean = imageEditorController?.imageEditorState?.redoAvailable ?: false
) {
ImageEditorGestureAwareControl(imageEditorController, modifier = modifier, extraCheck = {
canUndo || canRedo
}) {
Row(horizontalArrangement = spacedBy(8.dp)) {
IconButton(
enabled = canUndo,
onClick = {
imageEditorController?.undo()
},
colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5)
) {
Icon(
imageVector = SignalIcons.Undo.imageVector,
contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__undo_edit)
)
}
IconButton(
enabled = canRedo,
onClick = {
imageEditorController?.redo()
},
colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5)
) {
Icon(
imageVector = SignalIcons.Redo.imageVector,
contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__redo_edit)
)
}
}
}
}
@DayNightPreviews
@Composable
private fun ImageEditorUndoRedoButtonsPreview() {
Previews.Preview {
ImageEditorUndoRedoButtons(
imageEditorController = remember {
ImageController(EditorModel.create(0x0)).apply {
enterDrawMode()
}
},
canUndo = true,
canRedo = true
)
}
}
@@ -51,7 +51,16 @@
<!-- Content description for button to enable brush mode -->
<string name="DrawModeColorBar__brush">Brush</string>
<!-- Label for the button that undoes every edit made to the image, returning it to its original state -->
<string name="ImageEditorClearAllButton__clear_all">Clear all</string>
<!-- Content description for the button that undoes the last edit made to the image -->
<string name="ImageEditorUndoRedoButtons__undo_edit">Undo edit</string>
<!-- Content description for the button that redoes the last undone edit made to the image -->
<string name="ImageEditorUndoRedoButtons__redo_edit">Redo edit</string>
<!-- Video editor play button content description -->
<string name="VideoEditorHud_play_video_description">Play video</string>
<!-- CameraFragment -->